package api

import (
	"encoding/json"
	"errors"
	"net/http"
	"regexp"
	"time"

	"github.com/jackc/pgerrcode"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"golang.org/x/crypto/bcrypt"

	"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
	"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// usernameRe enforces a conservative username shape: 3-32 chars,
// alphanumeric + underscore + hyphen. The same regex is reused on
// the frontend for live validation.
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)

const minPasswordLength = 8

type registerReq struct {
	Username    string  `json:"username"`
	Password    string  `json:"password"`
	InviteToken string  `json:"invite_token"`
	DisplayName *string `json:"display_name"`
}

// handleRegister implements POST /api/auth/register.
//
// Flow:
//  1. Validate username/password format + minimum lengths.
//  2. Look up registration_settings.mode and current user count.
//  3. If users table is empty: insert via CreateUserFirstAdminRace
//     (sets is_admin from a SELECT NOT EXISTS subquery). Skip invite
//     check.
//  4. Else if mode == 'invite_only': atomically redeem invite via
//     RedeemInvite (returns rows-affected; 0 means no usable
//     invite — reject 400).
//  5. Else (mode == 'open'): proceed without invite check.
//  6. Hash password, mint session token, insert session row, set
//     cookie, return user + token.
//  7. Audit ActionRegister and (if invite was used) ActionInviteRedeem.
//
// Errors:
//   - 400 invalid_body / username_invalid / password_too_short
//   - 400 invite_required (invite_only mode, no token supplied)
//   - 400 invite_invalid (token doesn't exist, expired, or already redeemed)
//   - 409 username_taken (PG unique violation on users.username)
//   - 500 server_error otherwise
func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
	var req registerReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, apierror.BadRequest("invalid_body", "invalid JSON body"))
		return
	}
	if !usernameRe.MatchString(req.Username) {
		writeErr(w, apierror.BadRequest("username_invalid", "username must be 3-32 chars, alphanumeric + underscore + hyphen"))
		return
	}
	if len(req.Password) < minPasswordLength {
		writeErr(w, apierror.BadRequest("password_too_short", "password must be at least 8 chars"))
		return
	}

	q := dbq.New(h.pool)

	// Determine whether this is the empty-users (bootstrap-admin) case.
	userCount, err := q.CountUsers(r.Context())
	if err != nil {
		h.logger.Error("register: count users failed", "err", err)
		writeErr(w, apierror.Internal(err))
		return
	}

	// Validate invite (skipped on empty-users state; skipped in 'open' mode).
	usedInviteToken := ""
	if userCount > 0 {
		mode, err := q.GetRegistrationMode(r.Context())
		if err != nil {
			h.logger.Error("register: get mode failed", "err", err)
			writeErr(w, apierror.Internal(err))
			return
		}
		if mode == "invite_only" {
			if req.InviteToken == "" {
				writeErr(w, apierror.BadRequest("invite_required", "an invite token is required to register"))
				return
			}
			// Pre-validate existence + non-redeemed + non-expired so we fail
			// fast on bad tokens before creating the user.
			invite, ierr := q.GetInviteByToken(r.Context(), req.InviteToken)
			if ierr != nil {
				if errors.Is(ierr, pgx.ErrNoRows) {
					writeErr(w, apierror.BadRequest("invite_invalid", "invite token not found"))
					return
				}
				h.logger.Error("register: get invite failed", "err", ierr)
				writeErr(w, apierror.Internal(ierr))
				return
			}
			if invite.RedeemedAt.Valid || invite.ExpiresAt.Time.Before(time.Now()) {
				writeErr(w, apierror.BadRequest("invite_invalid", "invite token is expired or already redeemed"))
				return
			}
			usedInviteToken = req.InviteToken
		}
	}

	// Hash password.
	hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
	if err != nil {
		h.logger.Error("register: hash password failed", "err", err)
		writeErr(w, apierror.Internal(err))
		return
	}

	// Mint API token (same primitive used by bootstrap).
	apiToken, err := auth.MintSessionToken()
	if err != nil {
		h.logger.Error("register: mint api_token failed", "err", err)
		writeErr(w, apierror.Internal(err))
		return
	}

	// Insert user (race-safe-first-admin variant).
	user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{
		Username:     req.Username,
		PasswordHash: string(hash),
		ApiToken:     apiToken,
		DisplayName:  req.DisplayName,
	})
	if err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
			writeErr(w, apierror.Conflict("username_taken", "username is already taken"))
			return
		}
		h.logger.Error("register: create user failed", "err", err)
		writeErr(w, apierror.Internal(err))
		return
	}

	// Atomic invite redeem (if applicable). Now we have user.ID for redeemed_by.
	if usedInviteToken != "" {
		rows, rerr := q.RedeemInvite(r.Context(), dbq.RedeemInviteParams{
			Token:      usedInviteToken,
			RedeemedBy: user.ID,
		})
		if rerr != nil || rows != 1 {
			// Race: another caller redeemed between our pre-check and now.
			// User was already inserted — the redeem failed but the user
			// row exists. Best to leave the user in place (operator can
			// promote them manually) but surface a clear log entry.
			h.logger.Error("register: invite redeem failed; user inserted but invite not consumed",
				"user_id", uuidToString(user.ID), "rows", rows, "err", rerr)
			// Don't fail the response — the user IS registered.
		}
	}

	// Mint session token + cookie (same shape as handleLogin).
	sessionToken, err := auth.MintSessionToken()
	if err != nil {
		h.logger.Error("register: mint session failed", "err", err)
		writeErr(w, apierror.Internal(err))
		return
	}
	if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
		UserID:    user.ID,
		TokenHash: auth.HashSessionToken(sessionToken),
		UserAgent: r.UserAgent(),
		Ip:        auth.ClientIP(r, h.netSettings.Hops()),
	}); err != nil {
		h.logger.Error("register: insert session failed", "err", err)
		writeErr(w, apierror.Internal(err))
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name:     auth.SessionCookieName,
		Value:    sessionToken,
		Path:     "/",
		HttpOnly: true,
		Secure:   r.TLS != nil,
		SameSite: http.SameSiteStrictMode,
		MaxAge:   int(sessionCookieMaxAge.Seconds()),
	})

	// Audit (best-effort; a failed audit write does NOT fail the user-
	// facing operation).
	auditMeta := map[string]any{}
	if user.IsAdmin {
		auditMeta["first_admin"] = true
	}
	audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionRegister, auditMeta)
	if usedInviteToken != "" {
		audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionInviteRedeem,
			map[string]any{"token": usedInviteToken})
	}

	// Register the new user with the playlist scheduler so their daily
	// builds are scheduled without waiting for the hourly reconciliation
	// pass. Failure here is logged but doesn't fail registration — the
	// next hourly pass will pick them up.
	if h.playlistScheduler != nil {
		if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil {
			h.logger.Warn("api: scheduler refresh after registration",
				"user_id", uuidToString(user.ID), "err", err)
		}
	}

	writeJSON(w, http.StatusOK, LoginResponse{
		Token: sessionToken,
		User: UserView{
			ID:       user.ID,
			Username: user.Username,
			IsAdmin:  user.IsAdmin,
		},
	})
}
