package auth

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/example/sessionguard/internal/model"
	"golang.org/x/oauth2"
)

const backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"

type AccessSessionStore interface {
	PutAuthSession(model.AuthSession) error
	GetAuthSession(hash string) (model.AuthSession, bool)
	DeleteAuthSession(hash string) error
	RevokeAuthSessions(sid, sub string) (int, error)
	CleanupAuthSessions(time.Time) error
}

type accessPending struct {
	Nonce        string
	CodeVerifier string
	ReturnURL    string
	Exp          time.Time
}

type AccessManager struct {
	cfg        model.AccessAuthConfig
	provider   *oidc.Provider
	verifier   *oidc.IDTokenVerifier
	logoutVer  *oidc.IDTokenVerifier
	oauth      oauth2.Config
	sessions   AccessSessionStore
	endSession string
	mu         sync.Mutex
	pending    map[string]accessPending
	logoutSeen map[string]time.Time
}

func NewAccess(ctx context.Context, cfg model.AccessAuthConfig, sessions AccessSessionStore) (*AccessManager, error) {
	if !cfg.Enabled {
		return nil, nil
	}
	if sessions == nil {
		return nil, errors.New("access auth session store is required")
	}
	p, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/"))
	if err != nil {
		return nil, err
	}
	var discovery struct {
		EndSessionEndpoint string `json:"end_session_endpoint"`
	}
	_ = p.Claims(&discovery)
	return &AccessManager{
		cfg:      cfg,
		provider: p,
		verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
		// Back-channel logout tokens are not ID tokens and may omit exp. We
		// still verify issuer, audience and signature, then validate the
		// logout-specific claims below.
		logoutVer: p.Verifier(&oidc.Config{ClientID: cfg.ClientID, SkipExpiryCheck: true}),
		oauth: oauth2.Config{
			ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret,
			Endpoint: p.Endpoint(), RedirectURL: cfg.RedirectURL,
			Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
		},
		sessions:   sessions,
		endSession: discovery.EndSessionEndpoint,
		pending:    map[string]accessPending{},
		logoutSeen: map[string]time.Time{},
	}, nil
}

func (m *AccessManager) Register(mux *http.ServeMux) {
	if m == nil {
		return
	}
	mux.HandleFunc("/auth/verify", m.Verify)
	mux.HandleFunc("GET /auth/login", m.Login)
	mux.HandleFunc("GET /auth/oidc/callback", m.Callback)
	mux.HandleFunc("GET /auth/logout", m.Logout)
	mux.HandleFunc("POST /auth/logout", m.Logout)
	mux.HandleFunc("POST /auth/backchannel-logout", m.BackchannelLogout)
	mux.HandleFunc("GET /auth/status", m.Status)
}

func (m *AccessManager) Login(w http.ResponseWriter, r *http.Request) {
	_ = m.sessions.CleanupAuthSessions(time.Now().UTC())
	target := m.validReturnURL(r.URL.Query().Get("return"))
	state, nonce := randomAccessToken(24), randomAccessToken(24)
	verifier := oauth2.GenerateVerifier()
	m.mu.Lock()
	m.prunePendingLocked(time.Now())
	m.pending[state] = accessPending{Nonce: nonce, CodeVerifier: verifier, ReturnURL: target, Exp: time.Now().Add(5 * time.Minute)}
	m.mu.Unlock()

	// One state cookie per login flow avoids the common multi-tab race where a
	// second login overwrites the first flow's single state cookie.
	http.SetCookie(w, &http.Cookie{
		Name: stateCookieName(state), Value: state, Path: m.externalCallbackPath(),
		HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode,
		MaxAge: 300,
	})
	http.Redirect(w, r, m.oauth.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(verifier)), http.StatusFound)
}

func (m *AccessManager) Callback(w http.ResponseWriter, r *http.Request) {
	if e := r.URL.Query().Get("error"); e != "" {
		http.Error(w, "OIDC: "+e, http.StatusUnauthorized)
		return
	}
	state := strings.TrimSpace(r.URL.Query().Get("state"))
	if state == "" {
		http.Error(w, "missing OIDC state", http.StatusUnauthorized)
		return
	}
	cookieName := stateCookieName(state)
	c, err := r.Cookie(cookieName)
	if err != nil || c.Value != state {
		http.Error(w, "OIDC state is not bound to this browser", http.StatusUnauthorized)
		return
	}
	clearCookie(w, cookieName, "", m.externalCallbackPath(), m.cfg.SecureCookie)

	m.mu.Lock()
	p, ok := m.pending[state]
	delete(m.pending, state)
	m.mu.Unlock()
	if !ok || time.Now().After(p.Exp) {
		http.Error(w, "invalid or expired OIDC state", http.StatusUnauthorized)
		return
	}

	tok, err := m.oauth.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(p.CodeVerifier))
	if err != nil {
		http.Error(w, "OIDC token exchange failed", http.StatusUnauthorized)
		return
	}
	rawIDToken, ok := tok.Extra("id_token").(string)
	if !ok || strings.TrimSpace(rawIDToken) == "" {
		http.Error(w, "missing id_token", http.StatusUnauthorized)
		return
	}
	idToken, err := m.verifier.Verify(r.Context(), rawIDToken)
	if err != nil {
		http.Error(w, "invalid id_token", http.StatusUnauthorized)
		return
	}
	if idToken.Nonce != p.Nonce {
		http.Error(w, "invalid OIDC nonce", http.StatusUnauthorized)
		return
	}

	var claims map[string]any
	if err := idToken.Claims(&claims); err != nil {
		http.Error(w, "invalid OIDC claims", http.StatusUnauthorized)
		return
	}
	username := claimString(claims, m.cfg.UsernameClaim)
	if username == "" {
		// Pocket ID documents preferred_username and it is a safer Guacamole
		// identity than display-name. Never invent a fallback identity.
		username = claimString(claims, "preferred_username")
	}
	if username == "" {
		http.Error(w, "OIDC token has no usable username claim", http.StatusForbidden)
		return
	}
	groups := claimStrings(claims, "groups")
	if !allowedGroups(groups, m.cfg.AllowedGroups) {
		http.Error(w, "user is not in an allowed access group", http.StatusForbidden)
		return
	}

	browserToken := randomAccessToken(32)
	now := time.Now().UTC()
	sess := model.AuthSession{
		ID: randomAccessToken(12), TokenHash: hashAccessToken(browserToken), Subject: idToken.Subject,
		SID: claimString(claims, "sid"), Username: username,
		Email: claimString(claims, "email"), Name: claimString(claims, "name"),
		Groups: groups, IDToken: rawIDToken, CreatedAt: now,
		ExpiresAt: now.Add(time.Duration(m.cfg.SessionHours) * time.Hour),
	}
	if err := m.sessions.PutAuthSession(sess); err != nil {
		http.Error(w, "could not create access session", http.StatusInternalServerError)
		return
	}
	m.setSessionCookie(w, browserToken, int(time.Until(sess.ExpiresAt).Seconds()))
	http.Redirect(w, r, p.ReturnURL, http.StatusFound)
}

func (m *AccessManager) Verify(w http.ResponseWriter, r *http.Request) {
	sess, ok := m.sessionFromRequest(r)
	if !ok {
		target := m.forwardedTarget(r)
		http.Redirect(w, r, m.loginURL(target), http.StatusFound)
		return
	}
	// These are the only identity headers Traefik should copy to Guacamole.
	// authResponseHeaders replaces conflicting client-provided values.
	w.Header().Set("X-Guacamole-User", sess.Username)
	w.Header().Set("X-SessionGuard-User", sess.Username)
	if sess.Email != "" {
		w.Header().Set("X-SessionGuard-Email", sess.Email)
	}
	if len(sess.Groups) > 0 {
		w.Header().Set("X-SessionGuard-Groups", strings.Join(sess.Groups, ","))
	}
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(http.StatusOK)
}

func (m *AccessManager) Status(w http.ResponseWriter, r *http.Request) {
	sess, ok := m.sessionFromRequest(r)
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	if !ok {
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = w.Write([]byte(`{"authenticated":false}`))
		return
	}
	_ = json.NewEncoder(w).Encode(map[string]any{
		"authenticated": true, "username": sess.Username, "email": sess.Email,
		"groups": sess.Groups, "expires_at": sess.ExpiresAt,
	})
}

func (m *AccessManager) Logout(w http.ResponseWriter, r *http.Request) {
	sess, _ := m.sessionFromRequest(r)
	if c, err := r.Cookie(m.cfg.CookieName); err == nil {
		_ = m.sessions.DeleteAuthSession(hashAccessToken(c.Value))
	}
	clearCookie(w, m.cfg.CookieName, m.cfg.CookieDomain, "/", m.cfg.SecureCookie)

	target := strings.TrimSpace(m.cfg.LogoutRedirectURL)
	if target == "" {
		target = m.validReturnURL(r.URL.Query().Get("return"))
	}
	if m.endSession == "" {
		http.Redirect(w, r, target, http.StatusFound)
		return
	}
	u, err := url.Parse(m.endSession)
	if err != nil {
		http.Redirect(w, r, target, http.StatusFound)
		return
	}
	q := u.Query()
	q.Set("client_id", m.cfg.ClientID)
	if sess.IDToken != "" {
		q.Set("id_token_hint", sess.IDToken)
	}
	if target != "" {
		q.Set("post_logout_redirect_uri", target)
	}
	u.RawQuery = q.Encode()
	http.Redirect(w, r, u.String(), http.StatusFound)
}

func (m *AccessManager) BackchannelLogout(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "invalid form", http.StatusBadRequest)
		return
	}
	raw := strings.TrimSpace(r.Form.Get("logout_token"))
	if raw == "" {
		http.Error(w, "missing logout_token", http.StatusBadRequest)
		return
	}
	tok, err := m.logoutVer.Verify(r.Context(), raw)
	if err != nil {
		http.Error(w, "invalid logout_token", http.StatusBadRequest)
		return
	}
	var claims struct {
		SID    string                     `json:"sid"`
		Sub    string                     `json:"sub"`
		Nonce  string                     `json:"nonce"`
		JTI    string                     `json:"jti"`
		IAT    int64                      `json:"iat"`
		Events map[string]json.RawMessage `json:"events"`
	}
	if err := tok.Claims(&claims); err != nil {
		http.Error(w, "invalid logout_token claims", http.StatusBadRequest)
		return
	}
	if claims.Nonce != "" || claims.Events == nil {
		http.Error(w, "invalid logout_token claims", http.StatusBadRequest)
		return
	}
	if _, ok := claims.Events[backchannelLogoutEvent]; !ok {
		http.Error(w, "missing backchannel logout event", http.StatusBadRequest)
		return
	}
	if claims.SID == "" && claims.Sub == "" {
		http.Error(w, "logout_token has neither sid nor sub", http.StatusBadRequest)
		return
	}
	if strings.TrimSpace(claims.JTI) == "" {
		http.Error(w, "logout_token has no jti", http.StatusBadRequest)
		return
	}
	if claims.IAT == 0 || time.Since(time.Unix(claims.IAT, 0)) > 10*time.Minute || time.Until(time.Unix(claims.IAT, 0)) > 5*time.Minute {
		http.Error(w, "logout_token iat outside allowed window", http.StatusBadRequest)
		return
	}
	if !m.acceptLogoutJTI(claims.JTI, time.Now()) {
		http.Error(w, "logout_token replayed", http.StatusBadRequest)
		return
	}
	if _, err := m.sessions.RevokeAuthSessions(claims.SID, claims.Sub); err != nil {
		http.Error(w, "could not revoke access session", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
}

func (m *AccessManager) sessionFromRequest(r *http.Request) (model.AuthSession, bool) {
	c, err := r.Cookie(m.cfg.CookieName)
	if err != nil || strings.TrimSpace(c.Value) == "" {
		return model.AuthSession{}, false
	}
	return m.sessions.GetAuthSession(hashAccessToken(c.Value))
}

func (m *AccessManager) setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
	http.SetCookie(w, &http.Cookie{
		Name: m.cfg.CookieName, Value: token, Path: "/", Domain: m.cfg.CookieDomain,
		HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode,
		MaxAge: maxAge,
	})
}

func (m *AccessManager) forwardedTarget(r *http.Request) string {
	proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
	host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
	uri := strings.TrimSpace(r.Header.Get("X-Forwarded-Uri"))
	if proto == "" {
		proto = "https"
	}
	if uri == "" {
		uri = "/"
	}
	if host == "" {
		return m.cfg.LogoutRedirectURL
	}
	return m.validReturnURL(proto + "://" + host + uri)
}

func (m *AccessManager) externalCallbackPath() string {
	u, err := url.Parse(m.cfg.RedirectURL)
	if err != nil || strings.TrimSpace(u.Path) == "" {
		return "/auth/oidc/callback"
	}
	return u.Path
}

func (m *AccessManager) loginURL(target string) string {
	u, _ := url.Parse(m.cfg.RedirectURL)
	// Preserve an external Traefik prefix such as /_sessionguard. The Master
	// itself sees /auth/* after StripPrefix, while the browser must be sent to
	// the externally routable prefixed URL.
	base := strings.TrimSuffix(u.Path, "/oidc/callback")
	if base == u.Path {
		base = strings.TrimSuffix(u.Path, "/")
	}
	u.Path = base + "/login"
	u.RawQuery = url.Values{"return": []string{target}}.Encode()
	return u.String()
}

func (m *AccessManager) validReturnURL(raw string) string {
	fallback := strings.TrimSpace(m.cfg.LogoutRedirectURL)
	u, err := url.Parse(strings.TrimSpace(raw))
	if err != nil || u.Scheme != "https" || u.Hostname() == "" {
		return fallback
	}
	host := strings.ToLower(u.Hostname())
	for _, allowed := range m.cfg.AllowedHosts {
		allowed = strings.ToLower(strings.TrimSpace(allowed))
		if allowed == host {
			return u.String()
		}
		if strings.HasPrefix(allowed, "*.") && strings.HasSuffix(host, allowed[1:]) {
			return u.String()
		}
	}
	// If no allow-list was provided, constrain redirects to the configured
	// cookie domain or, for host-only cookies, to logout_redirect_url. This is
	// still closed against arbitrary open redirects.
	if len(m.cfg.AllowedHosts) == 0 {
		if m.cfg.CookieDomain != "" {
			d := strings.TrimPrefix(strings.ToLower(m.cfg.CookieDomain), ".")
			if host == d || strings.HasSuffix(host, "."+d) {
				return u.String()
			}
		}
		if f, err := url.Parse(fallback); err == nil && strings.EqualFold(f.Hostname(), host) {
			return u.String()
		}
	}
	return fallback
}

func (m *AccessManager) acceptLogoutJTI(jti string, now time.Time) bool {
	m.mu.Lock()
	defer m.mu.Unlock()
	for k, exp := range m.logoutSeen {
		if !now.Before(exp) {
			delete(m.logoutSeen, k)
		}
	}
	if _, exists := m.logoutSeen[jti]; exists {
		return false
	}
	m.logoutSeen[jti] = now.Add(15 * time.Minute)
	return true
}

func (m *AccessManager) prunePendingLocked(now time.Time) {
	for k, p := range m.pending {
		if now.After(p.Exp) {
			delete(m.pending, k)
		}
	}
}

func allowedGroups(got, allowed []string) bool {
	if len(allowed) == 0 {
		return true
	}
	set := map[string]struct{}{}
	for _, g := range got {
		set[strings.ToLower(strings.TrimSpace(g))] = struct{}{}
	}
	for _, g := range allowed {
		if _, ok := set[strings.ToLower(strings.TrimSpace(g))]; ok {
			return true
		}
	}
	return false
}

func claimString(claims map[string]any, key string) string {
	v, ok := claims[key]
	if !ok {
		return ""
	}
	if s, ok := v.(string); ok {
		return strings.TrimSpace(s)
	}
	return ""
}

func claimStrings(claims map[string]any, key string) []string {
	v, ok := claims[key]
	if !ok {
		return nil
	}
	switch x := v.(type) {
	case []any:
		out := make([]string, 0, len(x))
		for _, e := range x {
			if s, ok := e.(string); ok && strings.TrimSpace(s) != "" {
				out = append(out, strings.TrimSpace(s))
			}
		}
		return out
	case []string:
		return append([]string(nil), x...)
	case string:
		if strings.TrimSpace(x) != "" {
			return []string{strings.TrimSpace(x)}
		}
	}
	return nil
}

func randomAccessToken(n int) string {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return base64.RawURLEncoding.EncodeToString(b)
}
func hashAccessToken(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func stateCookieName(state string) string {
	if len(state) > 16 {
		state = state[:16]
	}
	return "sg_access_state_" + state
}
func clearCookie(w http.ResponseWriter, name, domain, path string, secure bool) {
	http.SetCookie(w, &http.Cookie{Name: name, Value: "", Domain: domain, Path: path, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)})
}
