// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"errors"
	"log/slog"
	"net"
	"net/http"
	"os"
	"sync"
	"time"

	"git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository"
	"github.com/gorilla/sessions"
	"golang.org/x/time/rate"
)

type Authenticator interface {
	CanLogin(user *repository.AppUser, username string, rw http.ResponseWriter, r *http.Request) (*repository.AppUser, bool)
	Login(user *repository.AppUser, rw http.ResponseWriter, r *http.Request) (*repository.AppUser, error)
}

var (
	initOnce     sync.Once
	authInstance *Authentication
)

var ipUserLimiters sync.Map

func getIPUserLimiter(ip, username string) *rate.Limiter {
	key := ip + ":" + username
	limiter, ok := ipUserLimiters.Load(key)
	if !ok {
		newLimiter := rate.NewLimiter(rate.Every(time.Hour/10), 10)
		ipUserLimiters.Store(key, newLimiter)
		return newLimiter
	}
	return limiter.(*rate.Limiter)
}

type Authentication struct {
	sessionStore   *sessions.CookieStore
	LocalAuth      *LocalAuthenticator
	authenticators []Authenticator
	SessionMaxAge  time.Duration
}

func (auth *Authentication) AuthViaSession(
	rw http.ResponseWriter,
	r *http.Request,
) (*repository.AppUser, error) {
	session, err := auth.sessionStore.Get(r, "session")
	if err != nil {
		slog.Error("Error while getting session store")
		return nil, err
	}

	if session.IsNew {
		return nil, nil
	}

	// TODO: Check if session keys exist
	username, _ := session.Values["username"].(string)
	return &repository.AppUser{
		UserName: username,
	}, nil
}

func Init() {
	initOnce.Do(func() {
		authInstance = &Authentication{}

		sessKey := os.Getenv("SESSION_KEY")
		if sessKey == "" {
			slog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
			bytes := make([]byte, 32)
			if _, err := rand.Read(bytes); err != nil {
				slog.Error("Error while initializing authentication -> failed to generate random bytes for session key")
				os.Exit(1)
			}
			authInstance.sessionStore = sessions.NewCookieStore(bytes)
		} else {
			bytes, err := base64.StdEncoding.DecodeString(sessKey)
			if err != nil {
				slog.Error("Error while initializing authentication -> decoding session key failed")
				os.Exit(1)
			}
			authInstance.sessionStore = sessions.NewCookieStore(bytes)
		}

		if d, err := time.ParseDuration("24h"); err == nil {
			authInstance.SessionMaxAge = d
		}

		authInstance.LocalAuth = &LocalAuthenticator{}
		if err := authInstance.LocalAuth.Init(); err != nil {
			slog.Error("Error while initializing authentication -> localAuth init failed")
			os.Exit(1)
		}
		authInstance.authenticators = append(authInstance.authenticators, authInstance.LocalAuth)
	})
}

func GetAuthInstance() *Authentication {
	if authInstance == nil {
		slog.Error("Authentication module not initialized!")
	}

	return authInstance
}

func (auth *Authentication) SaveSession(rw http.ResponseWriter,
	r *http.Request, user *repository.AppUser,
) error {
	session, err := auth.sessionStore.New(r, "session")
	if err != nil {
		slog.Error("session creation failed", "error", err.Error())
		http.Error(rw, err.Error(), http.StatusInternalServerError)
		return err
	}

	if auth.SessionMaxAge != 0 {
		session.Options.MaxAge = int(auth.SessionMaxAge.Seconds())
	}
	session.Options.Secure = false
	session.Options.SameSite = http.SameSiteStrictMode
	session.Values["username"] = user.UserName
	if err := auth.sessionStore.Save(r, rw, session); err != nil {
		slog.Warn("session save failed", "error", err.Error())
		http.Error(rw, err.Error(), http.StatusInternalServerError)
		return err
	}

	return nil
}

func (auth *Authentication) Login(
	onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			ip = r.RemoteAddr
		}

		username := r.FormValue("username")

		limiter := getIPUserLimiter(ip, username)
		if !limiter.Allow() {
			slog.Warn("AUTH/RATE > Too many login attempts for combination", "ip", ip, "username", username)
			onfailure(rw, r, errors.New("too many login attempts, try again in a few minutes"))
			return
		}

		var dbUser repository.AppUser
		if username != "" {
			var err error
			dbUser, err = repository.GetRepository().GetUser(r.Context(), username)
			if err != nil && err != sql.ErrNoRows {
				slog.Error("Error while loading user", "username", username)
			}
		}

		for _, authenticator := range auth.authenticators {
			var ok bool
			var user *repository.AppUser
			if user, ok = authenticator.CanLogin(&dbUser, username, rw, r); !ok {
				continue
			} else {
				slog.Debug("Can login with user", "username", user.UserName)
			}

			user, err := authenticator.Login(user, rw, r)
			if err != nil {
				slog.Warn("user login failed", "error", err.Error())
				onfailure(rw, r, err)
				return
			}

			if err := auth.SaveSession(rw, r, user); err != nil {
				return
			}

			slog.Info("login successfull", "user", user.UserName)
			ctx := context.WithValue(r.Context(), "user", user)

			if r.FormValue("redirect") != "" {
				http.RedirectHandler(r.FormValue("redirect"), http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
				return
			}

			http.RedirectHandler("/", http.StatusFound).ServeHTTP(rw, r.WithContext(ctx))
			return
		}

		slog.Debug("login failed: no authenticator applied")
		onfailure(rw, r, errors.New("no authenticator applied"))
	})
}

func (auth *Authentication) Auth(
	onsuccess http.Handler,
	onfailure func(rw http.ResponseWriter, r *http.Request, authErr error),
) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		user, err := auth.AuthViaSession(rw, r)
		if err != nil {
			slog.Info("auth -> authentication failed", "error", err.Error())
			http.Error(rw, err.Error(), http.StatusUnauthorized)
			return
		}
		if user != nil {
			ctx := context.WithValue(r.Context(), "user", user)
			onsuccess.ServeHTTP(rw, r.WithContext(ctx))
			return
		}

		slog.Info("auth -> authentication failed")
		onfailure(rw, r, errors.New("unauthorized (please login first)"))
	})
}

func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		session, err := auth.sessionStore.Get(r, "session")
		if err != nil {
			http.Error(rw, err.Error(), http.StatusInternalServerError)
			return
		}

		if !session.IsNew {
			session.Options.MaxAge = -1
			if err := auth.sessionStore.Save(r, rw, session); err != nil {
				http.Error(rw, err.Error(), http.StatusInternalServerError)
				return
			}
		}

		onsuccess.ServeHTTP(rw, r)
	})
}
