package authentication

import (
	// External
	werr "git.gibonuddevalla.se/go/wrappederror"
	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
	"github.com/lib/pq"

	// Internal
	appUser "notes2/user"

	// Standard
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"time"
)

type Manager struct {
	db         *sqlx.DB
	log        *slog.Logger
	secret     []byte
	ExpireDays int
}

func httpError(w http.ResponseWriter, err error) { // {{{
	j, _ := json.Marshal(struct {
		OK    bool
		Error string
	}{
		false,
		err.Error(),
	})
	w.Write(j)
} // }}}

func NewManager(db *sqlx.DB, log *slog.Logger, secret string, expireDays int) (mngr Manager, err error) { // {{{
	mngr.db = db
	mngr.log = log

	mngr.secret, err = hex.DecodeString(secret)
	mngr.ExpireDays = expireDays
	return
} // }}}
func validateTokenTimestamps(claims jwt.MapClaims) error { // {{{
	now := time.Now()

	if issuedAt, ok := claims["iat"].(float64); ok {
		if now.Unix() < int64(issuedAt) {
			return errors.New("Token is not valid yet")
		}
	} else {
		return errors.New("Token is missing iat")
	}

	if expires, ok := claims["exp"].(float64); ok {
		if now.Unix() > int64(expires) {
			return errors.New("Token has expired")
		}
	}

	return nil
} // }}}

func (mngr *Manager) GenerateToken(data map[string]any) (string, error) { // {{{
	// Create a new token object, specifying signing method and the claims
	// you would like it to contain.
	now := time.Now()
	data["iat"] = now.Unix()
	data["exp"] = now.Add(time.Hour * 24 * time.Duration(mngr.ExpireDays)).Unix()

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(data))

	// Sign and get the complete encoded token as a string using the secret.
	return token.SignedString(mngr.secret)
} // }}}
func (mngr *Manager) VerifyToken(tokenString string) (jwt.MapClaims, error) { // {{{
	// Parse takes the token string and a function for looking up the key. The latter is especially
	// useful if you use multiple keys for your application.  The standard is to use 'kid' in the
	// head of the token to identify which key to use, but the parsed token (head and claims) is provided
	// to the callback, providing flexibility.
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		// Don't forget to validate the alg is what you expect:
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
		}

		// hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
		return mngr.secret, nil
	})
	if err != nil {
		mngr.log.Error("authentication", "error", err)
		return nil, err
	}

	if claims, ok := token.Claims.(jwt.MapClaims); ok {
		err = validateTokenTimestamps(claims)
		if err != nil {
			return nil, err
		}
		return claims, nil
	} else {
		return nil, err
	}
} // }}}
func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Request) { // {{{
	var request struct {
		Username string
		Password string
	}

	body, _ := io.ReadAll(r.Body)
	err := json.Unmarshal(body, &request)
	if err != nil {
		mngr.log.Debug("authentication", "error", err)
		httpError(w, err)
		return
	}

	// Verify username and password against the db user table.
	authenticated, user, err := mngr.Authenticate(request.Username, request.Password)
	if err != nil {
		mngr.log.Error("authentication", "error", err)
		httpError(w, err)
		return
	}

	if !authenticated {
		mngr.log.Info("authentication", "username", request.Username, "status", "failed")
		httpError(w, errors.New("Authentication failed"))
		return
	}

	// A new token is generated with the information.
	var token string
	data := make(map[string]any)
	data["uid"] = user.ID
	data["login"] = user.Username
	data["name"] = user.Name

	data["cid"], err = mngr.NewClientUUID(user)
	if err != nil {
		mngr.log.Error("authentication", "error", err)
		httpError(w, err)
		return
	}

	token, err = mngr.GenerateToken(data)
	if err != nil {
		mngr.log.Error("authentication", "error", err)
		httpError(w, err)
		return
	}

	mngr.log.Info("authentication", "username", request.Username, "status", "accepted")
	j, _ := json.Marshal(struct {
		OK    bool
		User  appUser.User
		Token string
	}{true, user, token})
	w.Write(j)
} // }}}

func (mngr *Manager) Authenticate(username, password string) (authenticated bool, user appUser.User, err error) { // {{{
	var row *sql.Row
	row = mngr.db.QueryRow(`
                  SELECT id, username, name, preferences
                  FROM public.user
                  WHERE
                          LOWER(username) = LOWER($1) AND
                          password = password_hash(SUBSTRING(password FROM 1 FOR 32), $2::bytea)
                  `,
		username,
		password,
	)
	var data []byte
	err = row.Scan(&user.ID, &user.Username, &user.Name, &data)
	if err != nil && err.Error() == "sql: no rows in result set" {
		err = nil
		authenticated = false
		return
	}
	if err != nil {
		authenticated = false
		return
	}

	err = json.Unmarshal(data, &user.Preferences)
	if err != nil {
		authenticated = false
		return
	}

	authenticated = user.ID > 0
	return
} // }}}
func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists bool, err error) { // {{{
	_, err = mngr.db.Exec(`
		INSERT INTO public.user(username, password, name)
		VALUES(
			$1,
			public.password_hash(
				/* salt in hex */
				ENCODE(public.gen_random_bytes(16), 'hex'),

				/* password */
				$2::bytea
			),
			$3
		)
		`,
		username,
		password,
		name,
	)

	if err != nil {
		if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
			err = errors.New("User already exists")
			alreadyExists = true
			return
		}
	}

	return
} // }}}
func (mngr *Manager) ChangePassword(username, currentPassword, newPassword string, forceChange bool) (changed bool, err error) { // {{{
	var res sql.Result

	if forceChange {
		res, err = mngr.db.Exec(`
		UPDATE public.user
		SET
			"password" = public.password_hash(
				/* salt in hex */
				ENCODE(public.gen_random_bytes(16), 'hex'),

				/* password */
				$2::bytea
			)
		WHERE
			username = $1

		`,
			username,
			newPassword,
		)
	} else {
		res, err = mngr.db.Exec(`
		UPDATE public.user
		SET
			"password" = public.password_hash(
				/* salt in hex */
				ENCODE(public.gen_random_bytes(16), 'hex'),

				/* password */
				$3::bytea
			)
		WHERE
			username = $1 AND
			"password" = public.password_hash(SUBSTRING(password FROM 1 FOR 32), $2::bytea)

		`,
			username,
			currentPassword,
			newPassword,
		)
	}

	var rowsAffected int64
	rowsAffected, err = res.RowsAffected()
	if err != nil {
		return
	}

	changed = (rowsAffected == 1)
	return
} // }}}
func (mngr *Manager) NewClientUUID(user appUser.User) (clientUUID string, err error) { // {{{
	// Each client session has its own UUID.
	// Loop through until a unique one is established.
	var proposedClientUUID string
	var numSessions int
	for {
		proposedClientUUID = uuid.NewString()
		row := mngr.db.QueryRow("SELECT COUNT(id) FROM public.client WHERE client_uuid = $1", proposedClientUUID)
		err = row.Scan(&numSessions)
		if err != nil {
			err = werr.Wrap(err).WithData(proposedClientUUID)
			return
		}

		if numSessions > 0 {
			continue
		}

		_, err = mngr.db.Exec(`INSERT INTO public.client(user_id, client_uuid) VALUES($1, $2)`, user.ID, proposedClientUUID)
		if err != nil {
			err = werr.Wrap(err).WithData(proposedClientUUID)
			return
		}
		clientUUID = proposedClientUUID
		break
	}
	return
} // }}}
