package webui

import (
	"context"
	"net/http"
	"time"

	"mailgoserver/internal/db"
)

const (
	sessionCookieName = "mailgoserver_session"
	sessionTTL        = 7 * 24 * time.Hour
)

type ctxKey int

const (
	ctxUserKey ctxKey = iota
	ctxScopeKey
)

// accessScope is which domains the current admin can see/manage. A global admin
// bypasses the domain-ID check entirely; a scoped admin is restricted to exactly the
// domains in DomainIDs — computed once per request in requireAuth and reused by every
// handler via scopeFromContext, rather than re-querying esrv_admin_domain_access
// repeatedly within the same request.
type accessScope struct {
	Global    bool
	DomainIDs map[int64]bool
}

func (s accessScope) Allowed(domainID int64) bool {
	return s.Global || s.DomainIDs[domainID]
}

// IDs returns the accessible domain IDs as a slice — nil (not empty) for a global
// admin, since "nil" is the signal callers should treat as "no filter" rather than
// "empty set" when building an IN (...) clause or similar.
func (s accessScope) IDs() []int64 {
	if s.Global {
		return nil
	}
	ids := make([]int64, 0, len(s.DomainIDs))
	for id := range s.DomainIDs {
		ids = append(ids, id)
	}
	return ids
}

func scopeFromContext(r *http.Request) accessScope {
	s, _ := r.Context().Value(ctxScopeKey).(accessScope)
	return s
}

// adminMFASetupExempt is the strict allowlist for an admin with enforce_admin_mfa
// applying and no second factor yet: the isolated setup page itself, plus the actual
// form/API actions needed to complete TOTP or passkey enrollment. Everything else —
// including /account itself — redirects to /mfa-setup, so there's no visible
// navigation to any other route until MFA is actually configured.
func adminMFASetupExempt(method, path string) bool {
	if method == http.MethodGet {
		return path == Prefix+"/mfa-setup"
	}
	if method != http.MethodPost {
		return false
	}
	switch path {
	case Prefix + "/account/totp/setup", Prefix + "/account/totp/confirm",
		Prefix + "/account/passkey/begin", Prefix + "/account/passkey/finish":
		return true
	}
	return false
}

// requireGlobalAdmin gates a handler behind the current admin's scope being global —
// used for server-wide settings (Server Settings, Let's Encrypt) that a domain-scoped
// admin has no business reading or changing, even if they can guess the URL. 404 (not
// 403) matches requireDomainAccess's reasoning: a scoped admin shouldn't be able to
// tell "doesn't exist" from "exists but isn't mine" by probing.
func (a *App) requireGlobalAdmin(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if !scopeFromContext(r).Global {
			http.NotFound(w, r)
			return
		}
		next(w, r)
	}
}

// requireDomainAccess checks the current admin's scope covers domainID; if not, it
// writes a 404 (not 403 — a scoped admin shouldn't be able to distinguish "doesn't
// exist" from "exists but isn't mine" by probing IDs) and returns false, matching the
// existing "not found" handling every route already does for a missing resource.
func requireDomainAccess(w http.ResponseWriter, r *http.Request, domainID int64) bool {
	if scopeFromContext(r).Allowed(domainID) {
		return true
	}
	http.NotFound(w, r)
	return false
}

func (a *App) buildAccessScope(user *db.AdminUser) (accessScope, error) {
	if user.IsGlobalAdmin {
		return accessScope{Global: true}, nil
	}
	ids, err := a.DB.AccessibleDomainIDs(user.ID)
	if err != nil {
		return accessScope{}, err
	}
	m := make(map[int64]bool, len(ids))
	for _, id := range ids {
		m[id] = true
	}
	return accessScope{DomainIDs: m}, nil
}

func setSessionCookie(w http.ResponseWriter, token string, secure bool) {
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    token,
		Path:     "/",
		HttpOnly: true,
		Secure:   secure,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   int(sessionTTL.Seconds()),
	})
}

func clearSessionCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
}

// currentSession loads the session + user for the request's cookie, if any and valid
// (exists, not expired). A nil session/user (no error) means "not logged in".
func (a *App) currentSession(r *http.Request) (*db.AdminSession, *db.AdminUser, error) {
	c, err := r.Cookie(sessionCookieName)
	if err != nil || c.Value == "" {
		return nil, nil, nil
	}
	sess, err := a.DB.GetSession(c.Value)
	if err != nil || sess == nil {
		return nil, nil, err
	}
	if time.Now().After(sess.ExpiresAt) {
		_ = a.DB.DeleteSession(sess.Token)
		return nil, nil, nil
	}
	user, err := a.DB.GetAdminUserByID(sess.UserID)
	if err != nil || user == nil {
		return nil, nil, err
	}
	return sess, user, nil
}

func userFromContext(r *http.Request) *db.AdminUser {
	u, _ := r.Context().Value(ctxUserKey).(*db.AdminUser)
	return u
}

// requireAuth gates every admin route behind a valid, fully-authenticated session:
// logged in, second factor satisfied if one is enabled, and not stuck in the forced
// first-login credential change. Unauthenticated/incomplete requests are redirected
// to the right step of the login flow rather than shown an error.
func (a *App) requireAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		sess, user, err := a.currentSession(r)
		if err != nil {
			a.Logger.Error("session lookup: %v", err)
		}
		if sess == nil || user == nil {
			http.Redirect(w, r, Prefix+"/login?next="+r.URL.Path, http.StatusFound)
			return
		}

		// hasMFA: this account already has a second factor configured (TOTP or a
		// passkey) — distinct from sess.MFAVerified, which is about *this session*
		// having satisfied it.
		hasMFA := user.TOTPEnabled
		if !hasMFA {
			if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
				hasMFA = true
			}
		}
		if hasMFA && !sess.MFAVerified {
			http.Redirect(w, r, Prefix+"/login/mfa", http.StatusFound)
			return
		}

		if user.MustChangePassword && r.URL.Path != Prefix+"/first-login" {
			http.Redirect(w, r, Prefix+"/first-login", http.StatusFound)
			return
		}

		// enforce_admin_mfa applies to every admin, global or scoped — an admin with no
		// second factor yet is sent to the isolated /mfa-setup page (no sidebar, no
		// other route reachable except the actual totp/passkey setup actions) instead
		// of anywhere they'd otherwise have access, mirroring the must_change_password
		// gate above. Checked after must_change_password so a brand-new admin sets a
		// real password first.
		if !hasMFA && !user.MustChangePassword && a.Cfg.Section("Auth").Key("enforce_admin_mfa").MustBool(false) {
			if !adminMFASetupExempt(r.Method, r.URL.Path) {
				http.Redirect(w, r, Prefix+"/mfa-setup", http.StatusFound)
				return
			}
		}

		scope, err := a.buildAccessScope(user)
		if err != nil {
			a.Logger.Error("build access scope: %v", err)
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}

		ctx := context.WithValue(r.Context(), ctxUserKey, user)
		ctx = context.WithValue(ctx, ctxScopeKey, scope)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}
