package auth

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"log"
	"net/http"
	"strings"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/v2/bson"
	"golang.org/x/crypto/bcrypt"
)

// BcryptCost matches the control plane and sitesvc. Changing it here alone would
// make hashes inconsistent across services that may one day compare them.
const BcryptCost = 12

// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
// SHA-256 hash stored, 24-hour expiry.
//
// It is shared/mail's constant rather than a second copy because the
// verification email states the number of hours: a window that disagreed with
// what the email promised would expire links early with no explanation.
const VerifyWindow = sharedmail.VerifyWindow

// CreateCustomerUser creates an unverified HQ login with a chosen password and
// emails the verification link. Used by signup and by staff.
func CreateCustomerUser(ctx context.Context, accountID, email, password, accountRole string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
	if err != nil {
		return err
	}

	raw := make([]byte, 32)
	if _, err := rand.Read(raw); err != nil {
		return err
	}
	token := hex.EncodeToString(raw)
	sum := sha256.Sum256([]byte(token))
	expiry := time.Now().UTC().Add(VerifyWindow)

	u := models.CustomerUser{
		UserID:            uuid.NewString(),
		AccountID:         accountID,
		Email:             strings.ToLower(strings.TrimSpace(email)),
		PasswordHash:      string(hash),
		AccountRole:       accountRole,
		VerifyTokenHash:   hex.EncodeToString(sum[:]),
		VerifyTokenExpiry: &expiry,
		CreatedAt:         time.Now().UTC(),
	}
	if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
		return err
	}

	if err := mail.Default.SendVerification(u.Email, token); err != nil {
		// Undo the insert. A row whose verification link was never delivered is
		// worse than no row: it can never be signed in to, and it holds the
		// unique index on email, so the customer cannot sign up again with the
		// address they just used.
		//
		// Deliberately NOT on ctx. ctx is the HTTP request's, and the most likely
		// reason we are here is that the mail server stalled until the browser
		// gave up — which cancels ctx and makes this delete a silent no-op,
		// stranding exactly the row it exists to remove. That happened in
		// production against a port-465 server.
		rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
		defer cancel()
		if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
			log.Printf("signup: FAILED to roll back customer_user %s (%s) after mail error: %v",
				u.UserID, u.Email, dErr)
		}
		return err
	}
	return nil
}

// CreateInvitedUser creates a passwordless, unverified member of an existing
// account and emails them a link to set a password.
//
// The empty hash is load-bearing: bcrypt.CompareHashAndPassword against "" can
// never succeed, so the row cannot sign in and cannot usefully be projected
// into an instance until the invitee has been through /accept-invite. That is
// also why a grant refuses an unverified user.
func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accountRole string) error {
	raw := make([]byte, 32)
	if _, err := rand.Read(raw); err != nil {
		return err
	}
	token := hex.EncodeToString(raw)
	sum := sha256.Sum256([]byte(token))
	expiry := time.Now().UTC().Add(VerifyWindow)

	u := models.CustomerUser{
		UserID:            uuid.NewString(),
		AccountID:         accountID,
		Email:             strings.ToLower(strings.TrimSpace(email)),
		AccountRole:       accountRole,
		VerifyTokenHash:   hex.EncodeToString(sum[:]),
		VerifyTokenExpiry: &expiry,
		CreatedAt:         time.Now().UTC(),
	}
	if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
		return err
	}

	if err := mail.Default.SendInvite(u.Email, accountName, token); err != nil {
		// Same rollback rule, and the same detached context, as signup: a row
		// whose link was never delivered can never be signed in to and holds
		// the unique index on email against the person it was meant for.
		rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
		defer cancel()
		if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
			log.Printf("invite: FAILED to roll back customer_user %s (%s) after mail error: %v",
				u.UserID, u.Email, dErr)
		}
		return err
	}
	return nil
}

// HandleAcceptInvite consumes an invitation token and sets the password.
//
// Verification and password-setting are one step for an invitee, because the
// link IS the proof of address and there is nothing to verify separately.
func HandleAcceptInvite(c *gin.Context) {
	var body struct {
		Token    string `json:"token"`
		Password string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Token == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
		return
	}
	if len(body.Password) < 12 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
		return
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), BcryptCost)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
		return
	}

	sum := sha256.Sum256([]byte(body.Token))
	now := time.Now().UTC()
	res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
		bson.M{
			"verify_token_hash":   hex.EncodeToString(sum[:]),
			"verify_token_expiry": bson.M{"$gt": now},
		},
		bson.M{
			"$set":   bson.M{"verified_at": now, "password_hash": string(hash)},
			"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
		})
	if err != nil || res.MatchedCount == 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"accepted": true})
}

// HandleSignup creates a self-hosted customer: an account, an unverified user,
// and a verification email.
//
// Nothing is usable until the emailed link is opened, the same rule sitesvc
// already proves — so an address nobody controls cannot occupy an email or
// produce an account that can sign in.
func HandleSignup(c *gin.Context) {
	var body struct {
		Name     string `json:"name"`
		Email    string `json:"email"`
		Password string `json:"password"`
		Website  string `json:"website"` // honeypot; real users never fill it
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and password are required"})
		return
	}

	// Honeypot: answer exactly as success so a bot learns nothing.
	if strings.TrimSpace(body.Website) != "" {
		c.JSON(http.StatusCreated, gin.H{"pending": true})
		return
	}

	email := strings.ToLower(strings.TrimSpace(body.Email))
	ctx := c.Request.Context()

	if email == "" || len(body.Password) < 12 || strings.TrimSpace(body.Name) == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and a password of at least 12 characters are required"})
		return
	}
	if !allowAttempt("signup:"+email, c.ClientIP()) {
		c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
		return
	}

	if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
		// Same response as success. Telling a stranger the address is taken
		// confirms who has an account here.
		c.JSON(http.StatusCreated, gin.H{"pending": true})
		return
	}

	acct := models.Account{
		AccountID:    uuid.NewString(),
		Name:         strings.TrimSpace(body.Name),
		BillingEmail: email,
		Status:       models.AccountActive,
		CreatedAt:    time.Now().UTC(),
	}
	if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the account"})
		return
	}

	if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner); err != nil {
		// Roll the account back rather than strand one with no owner. Detached
		// from ctx for the same reason as the user rollback above: a stalled mail
		// server cancels the request, and a rollback that needs the request to
		// still be alive is a rollback that fails exactly when it is needed.
		log.Printf("signup: %s failed, rolling back account %s: %v", email, acct.AccountID, err)
		rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
		defer cancel()
		if _, dErr := db.Admin("accounts").DeleteOne(rbCtx, bson.M{"account_id": acct.AccountID}); dErr != nil {
			log.Printf("signup: FAILED to roll back account %s: %v", acct.AccountID, dErr)
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the verification email"})
		return
	}

	audit.Write(ctx, models.AuditEntry{
		Actor: email, Action: "customer.signup", AccountID: acct.AccountID, IP: c.ClientIP()})
	c.JSON(http.StatusCreated, gin.H{"pending": true})
}

// HandleVerify consumes a verification token.
func HandleVerify(c *gin.Context) {
	token := c.Query("token")
	if token == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
		return
	}
	sum := sha256.Sum256([]byte(token))
	now := time.Now().UTC()
	ctx := c.Request.Context()
	hashed := hex.EncodeToString(sum[:])

	// Peek first. An invited row has no password yet, so consuming its token
	// here would verify an account nobody can sign in to and burn the only
	// link that could fix it.
	var u models.CustomerUser
	if err := db.Admin("customer_users").FindOne(ctx, bson.M{
		"verify_token_hash":   hashed,
		"verify_token_expiry": bson.M{"$gt": now},
	}).Decode(&u); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
		return
	}
	if u.PasswordHash == "" {
		c.JSON(http.StatusOK, gin.H{"verified": false, "needs_password": true})
		return
	}

	res, err := db.Admin("customer_users").UpdateOne(ctx,
		bson.M{
			"verify_token_hash":   hashed,
			"verify_token_expiry": bson.M{"$gt": now},
		},
		bson.M{
			"$set":   bson.M{"verified_at": now},
			"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
		})
	if err != nil || res.MatchedCount == 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"verified": true})
}

// HandleCustomerLogin authenticates a self-hosted customer.
func HandleCustomerLogin(c *gin.Context) {
	var body struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
		return
	}
	email := strings.ToLower(strings.TrimSpace(body.Email))
	ctx := c.Request.Context()

	if !allowAttempt(email, c.ClientIP()) {
		c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
		return
	}

	reject := func(reason string) {
		audit.Write(ctx, models.AuditEntry{
			Actor: email, Action: "customer.login_failed", IP: c.ClientIP(), Detail: reason})
		c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
	}

	var u models.CustomerUser
	if err := db.Admin("customer_users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil {
		bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
		reject("unknown email")
		return
	}
	if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
		reject("bad password")
		return
	}
	if u.VerifiedAt == nil {
		// Distinct from genericAuthError on purpose: the address is already
		// known to be theirs, so there is nothing to disclose, and "check your
		// email" is the only useful thing to say.
		c.JSON(http.StatusForbidden, gin.H{"error": "verify your email address first"})
		return
	}

	id, err := Save(ctx, Session{
		UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: u.AccountID,
	})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
		return
	}
	SetCookie(c, id)
	clearAttempts(email)
	audit.Write(ctx, models.AuditEntry{
		Actor: email, Action: "customer.login", AccountID: u.AccountID, IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email})
}
