package auth

import (
	"context"
	cryptorand "crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"math/big"
	"math/bits"
	"strings"
	"time"
	"unicode"

	"github.com/TaruLab/keluargamart-backend/internal/mailer"
	"golang.org/x/crypto/bcrypt"
)

// Store is the persistence dependency of Service. Implemented by Repository;
// faked in unit tests.
type Store interface {
	UserByEmail(ctx context.Context, email string) (*User, error)
	UserByID(ctx context.Context, id int64) (*User, error)
	TokenByHash(ctx context.Context, hash string) (*Token, *User, error)
	InsertToken(ctx context.Context, t *Token) (int64, error)
	TouchLastUsed(ctx context.Context, tokenID int64) error
	DeleteToken(ctx context.Context, id int64) error
	DeleteTokensByUser(ctx context.Context, userID int64) error
	UpdateLastLogin(ctx context.Context, userID int64, loc *time.Location) error
	UpdateProfile(ctx context.Context, id int64, name, email, passwordHash *string) error
	CreateEcommerceAccount(ctx context.Context, in RegisterInput) (*User, error)
	SalesUserIDByEmployeeCode(ctx context.Context, code string) (int64, error)
	UpsertPasswordResetToken(ctx context.Context, email, token string) error
	UserByResetToken(ctx context.Context, token string) (*User, error)
	DeletePasswordResetToken(ctx context.Context, email string) error
	// Email verification (000152): hashed 6-digit codes, one row per
	// email+purpose; a resend upserts (replacing code + attempt counter).
	UpsertEmailVerificationCode(ctx context.Context, email, purpose, codeHash string) error
	GetEmailVerificationCode(ctx context.Context, email, purpose string) (*EmailVerificationCode, error)
	BumpEmailVerificationAttempts(ctx context.Context, email, purpose string) error
	DeleteEmailVerificationCode(ctx context.Context, email, purpose string) error
	MarkEmailVerified(ctx context.Context, email string) error
}

// EmailVerificationCode is a stored (hashed) OTP row for one email+purpose.
type EmailVerificationCode struct {
	Email     string
	Purpose   string
	CodeHash  string
	Attempts  int
	ExpiresAt time.Time
}

// Sentinel errors mapped to HTTP in the handler.
var (
	ErrInvalidCredentials = errors.New("invalid credentials")
	ErrAccountDisabled    = errors.New("account disabled")
	ErrNotFoundUser       = errors.New("user not found")
	ErrEmailTaken         = errors.New("email already registered")
	ErrRegisterInvalid    = errors.New("invalid registration input")
	ErrInvalidReferral    = errors.New("invalid referral code")
	// Email verification (000152).
	ErrVerificationPending = errors.New("email verification required") // 403: login before verifying
	ErrCodeInvalid         = errors.New("verification code invalid or expired")
	ErrCodeLocked          = errors.New("too many wrong attempts; request a new code")
	ErrAlreadyVerified     = errors.New("email already verified")
)

// RegisterInput is a self-service storefront signup.
type RegisterInput struct {
	AccountType string // "individual" | "business"
	Name        string // individual: person; business: PIC / contact person
	Email       string
	Password    string // plaintext on the way in; hashed before the Store call
	Phone       string
	CompanyName string // business only (required)
	TaxNumber   string // business only (optional NPWP)
	// ReferralCode is a business-only sales code (employees.employee_number,
	// e.g. EMP-002). Resolved to ReferralSalesID before provisioning; the
	// raw code never persists.
	ReferralCode    string
	ReferralSalesID int64 // resolved sales user id, 0 = self-registered
}

// Service implements auth use cases.
type Service struct {
	store      Store
	loc        *time.Location
	tokenTTL   time.Duration
	mailer     mailer.Mailer
	b2bBaseURL string
}

func NewService(store Store, loc *time.Location, tokenTTL time.Duration) *Service {
	if tokenTTL == 0 {
		tokenTTL = 24 * time.Hour
	}
	return &Service{store: store, loc: loc, tokenTTL: tokenTTL}
}

// WithMailer wires the outbound-email collaborator used by
// RequestPasswordReset to deliver the reset link. baseURL is the B2B
// storefront's public base URL the reset link is built against
// ("{baseURL}/reset-password?token={token}"). Both are optional: if mailer
// is nil, RequestPasswordReset still issues the token but cannot deliver it
// (matches other optional collaborators in this codebase, e.g. geo.Router).
func (s *Service) WithMailer(m mailer.Mailer, baseURL string) *Service {
	s.mailer = m
	s.b2bBaseURL = strings.TrimRight(baseURL, "/")
	return s
}

// LoginResult is returned by Login.
type LoginResult struct {
	Token string
	User  *User
}

// Login validates credentials (business-rules §7.1-7.2): unknown email,
// disabled account, wrong password all map to specific sentinel errors.
func (s *Service) Login(ctx context.Context, email, password string, appScope ...string) (*LoginResult, error) {
	u, err := s.store.UserByEmail(ctx, email)
	// L1: always run a bcrypt comparison of comparable cost on the failure paths
	// so response timing does not leak whether the email exists (user-existence
	// oracle). The not-found and disabled branches compare against a constant
	// dummy hash instead of skipping the work entirely.
	if err != nil {
		_ = bcrypt.CompareHashAndPassword(dummyTokenHash, []byte(password))
		if errors.Is(err, ErrNotFound) {
			return nil, ErrInvalidCredentials
		}
		return nil, err
	}
	if !u.IsActive {
		_ = bcrypt.CompareHashAndPassword(dummyTokenHash, []byte(password))
		return nil, ErrAccountDisabled
	}
	// Verify the password before revealing that an e-commerce account is pending
	// email verification. This preserves the generic invalid-credentials response
	// for callers who do not know the password (no account-state oracle).
	if bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil {
		return nil, ErrInvalidCredentials
	}

	targetApp := ""
	if len(appScope) > 0 {
		targetApp = strings.TrimSpace(strings.ToLower(appScope[0]))
	}

	// Email verification (000152): a self-registered e-commerce account that
	// has not verified its email cannot get a session in the e-commerce app.
	// When targeting another shell (ERP/POS) that this user is not authorized
	// to access, bypass ErrVerificationPending so downstream app-access
	// enforcement rejects with the proper shell-forbidden response instead
	// of confusing the user with an email verification prompt for an app they
	// cannot access anyway.
	isTargetingRestrictedShell := targetApp != "" &&
		targetApp != string(AppECommerce) &&
		!u.CanAccessApp(ApplicationAccess(targetApp))

	if !isTargetingRestrictedShell && u.ApplicationAccess == AppECommerce && u.EmailVerifiedAt == nil {
		return nil, ErrVerificationPending
	}

	plain, hash := IssueToken()
	now := time.Now().In(s.loc)
	exp := now.Add(s.tokenTTL)
	t := &Token{
		TokenableType: "App\\Models\\User",
		TokenableID:   u.ID,
		Name:          "api",
		TokenHash:     hash,
		Abilities:     "*",
		ExpiresAt:     &exp,
		CreatedAt:     now,
	}
	if _, err := s.store.InsertToken(ctx, t); err != nil {
		return nil, err
	}
	if err := s.store.UpdateLastLogin(ctx, u.ID, s.loc); err != nil {
		return nil, err
	}
	return &LoginResult{Token: plain, User: u}, nil
}

// Register provisions a new storefront buyer (+ linked customer). Since 000152
// the account starts UNVERIFIED: no session is issued here — a 6-digit code is
// emailed (via the injected Mailer) and POST /auth/verify-email swaps it for a
// session. The response therefore carries NO token; the handler returns 201
// with { email, verification_required: true }.
//
// The code is stored bcrypt-hashed (same posture as passwords) with a
// 10-minute TTL and a 5-attempt lock; resending upserts a fresh code.
func (s *Service) Register(ctx context.Context, in RegisterInput) (*RegisterResult, error) {
	in.Email = strings.ToLower(strings.TrimSpace(in.Email))
	in.Name = strings.TrimSpace(in.Name)
	in.CompanyName = strings.TrimSpace(in.CompanyName)
	in.Phone = strings.TrimSpace(in.Phone)
	in.TaxNumber = strings.TrimSpace(in.TaxNumber)
	in.ReferralCode = strings.ToUpper(strings.TrimSpace(in.ReferralCode))

	if in.Name == "" || !validEmail(in.Email) || len(in.Password) < 8 {
		return nil, ErrRegisterInvalid
	}
	switch in.AccountType {
	case "individual":
		// Referral is a business-only feature; an individual sending a code
		// is a contract violation, not a silent drop.
		if in.ReferralCode != "" {
			return nil, ErrRegisterInvalid
		}
	case "business":
		if in.CompanyName == "" {
			return nil, ErrRegisterInvalid
		}
		if in.ReferralCode != "" {
			salesID, err := s.store.SalesUserIDByEmployeeCode(ctx, in.ReferralCode)
			if err != nil {
				return nil, ErrInvalidReferral
			}
			in.ReferralSalesID = salesID
		}
	default:
		return nil, ErrRegisterInvalid
	}

	hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
	if err != nil {
		return nil, err
	}
	in.Password = string(hash)

	u, err := s.store.CreateEcommerceAccount(ctx, in)
	if err != nil {
		return nil, err
	}

	// Email verification (000152): issue + deliver the 6-digit code. A
	// delivery failure must NOT fail the registration — the account exists
	// and a resend endpoint covers lost mail — but the user must be told the
	// code path exists, so RegisterResult.VerificationRequired stays true.
	if err := s.issueEmailCode(ctx, u.Email, u.Name, "register"); err != nil {
		return nil, err
	}
	return &RegisterResult{Email: u.Email, VerificationRequired: true}, nil
}

// RegisterResult is the 201 response of POST /auth/register after 000152:
// no token — the session is only issued once the emailed code is verified.
type RegisterResult struct {
	Email                string `json:"email"`
	VerificationRequired bool   `json:"verification_required"`
}

// otpPurposeRegister is the only verification purpose in the system today;
// named so login-verification or recovery can reuse the table later.
const otpPurposeRegister = "register"

// issueEmailCode generates a 6-digit code, stores its bcrypt hash and emails
// the plaintext via the injected Mailer. Shared by register and resend.
func (s *Service) issueEmailCode(ctx context.Context, email, name, purpose string) error {
	// crypto/rand 6 digits, zero-padded — 10^6 space, rate-limited delivery.
	n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(1000000))
	if err != nil {
		return err
	}
	code := fmt.Sprintf("%06d", n.Int64())
	hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	if err := s.store.UpsertEmailVerificationCode(ctx, email, purpose, string(hash)); err != nil {
		return err
	}
	if s.mailer != nil {
		subject := "Kode Verifikasi KeluargaMart"
		body := fmt.Sprintf(
			"Halo %s,\n\nKode verifikasi akun KeluargaMart Anda adalah:\n\n    %s\n\n"+
				"Kode berlaku 10 menit. Jangan bagikan kode ini kepada siapa pun — "+
				"pihak KeluargaMart tidak pernah meminta kode ini.\n\n"+
				"Jika Anda tidak merasa mendaftar, abaikan email ini.",
			name, code)
		// Same posture as the reset link: transport failure must not change
		// the registration outcome; the resend endpoint is the recovery path.
		_ = s.mailer.Send(ctx, email, subject, body)
	}
	return nil
}

// ResendVerification re-issues the code for an existing unverified account.
// It answers identically whether or not the email is registered (and whether
// or not it was already verified) so the endpoint cannot enumerate accounts.
func (s *Service) ResendVerification(ctx context.Context, email string) error {
	email = strings.ToLower(strings.TrimSpace(email))
	if email == "" || !validEmail(email) {
		return ErrRegisterInvalid
	}
	user, err := s.store.UserByEmail(ctx, email)
	if err != nil || user.EmailVerifiedAt != nil {
		return nil // unknown or already verified: stay silent (anti-enumeration)
	}
	return s.issueEmailCode(ctx, user.Email, user.Name, otpPurposeRegister)
}

// VerifyEmail consumes a 6-digit code and, on success, marks the account
// verified and issues the session Register no longer hands out.
func (s *Service) VerifyEmail(ctx context.Context, email, code string) (*LoginResult, error) {
	email = strings.ToLower(strings.TrimSpace(email))
	code = strings.TrimSpace(code)
	if email == "" || !validEmail(email) {
		return nil, ErrRegisterInvalid
	}
	if len(code) != 6 {
		return nil, ErrCodeInvalid
	}
	user, err := s.store.UserByEmail(ctx, email)
	if err != nil {
		return nil, ErrCodeInvalid // same answer whether known or not
	}
	if user.EmailVerifiedAt != nil {
		return nil, ErrAlreadyVerified
	}
	row, err := s.store.GetEmailVerificationCode(ctx, email, otpPurposeRegister)
	if err != nil {
		return nil, ErrCodeInvalid // no code issued / consumed
	}
	if time.Now().In(s.loc).After(row.ExpiresAt) {
		return nil, ErrCodeInvalid
	}
	if row.Attempts >= 5 {
		return nil, ErrCodeLocked
	}
	if bcrypt.CompareHashAndPassword([]byte(row.CodeHash), []byte(code)) != nil {
		// Consume one attempt before rejecting: 5 wrong tries lock the row
		// until a resend replaces it.
		if berr := s.store.BumpEmailVerificationAttempts(ctx, email, otpPurposeRegister); berr != nil {
			return nil, berr
		}
		return nil, ErrCodeInvalid
	}
	if err := s.store.MarkEmailVerified(ctx, email); err != nil {
		return nil, err
	}
	if err := s.store.DeleteEmailVerificationCode(ctx, email, otpPurposeRegister); err != nil {
		return nil, err
	}
	// Session issued only now — register never had one to hand out.
	plain, thash := IssueToken()
	now := time.Now().In(s.loc)
	exp := now.Add(s.tokenTTL)
	if _, err := s.store.InsertToken(ctx, &Token{
		TokenableType: "App\\Models\\User",
		TokenableID:   user.ID,
		Name:          "api",
		TokenHash:     thash,
		Abilities:     "*",
		ExpiresAt:     &exp,
		CreatedAt:     now,
	}); err != nil {
		return nil, err
	}
	return &LoginResult{Token: plain, User: user}, nil
}

// Authenticate resolves a bearer token plaintext to its user. Returns sentinel
// errors on failure so the middleware can map HTTP status.
func (s *Service) Authenticate(ctx context.Context, bearer string) (*User, int64, error) {
	hash := HashToken(bearer)
	t, u, err := s.store.TokenByHash(ctx, hash)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return nil, 0, ErrInvalidCredentials
		}
		return nil, 0, err
	}
	if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) {
		return nil, 0, ErrInvalidCredentials
	}
	if !u.IsActive {
		return nil, 0, ErrAccountDisabled
	}
	// last_used_at refresh is best-effort and cheap; failure must not break the
	// request so we ignore the error.
	_ = s.store.TouchLastUsed(ctx, t.ID)
	return u, t.ID, nil
}

// Logout revokes the current token.
func (s *Service) Logout(ctx context.Context, tokenID int64) error {
	return s.store.DeleteToken(ctx, tokenID)
}

// Me fetches the fresh user row for the authenticated principal.
func (s *Service) Me(ctx context.Context, user *User) (*User, error) {
	u, err := s.store.UserByID(ctx, user.ID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return nil, ErrNotFoundUser
		}
		return nil, err
	}
	return u, nil
}

// UpdateProfile updates name/email and optionally resets the password (min 8
// chars). A password change requires the current password and revokes every
// existing session so a stolen token cannot outlive the credential change.
// Only non-empty fields are changed; at least one must be non-empty.
func (s *Service) UpdateProfile(ctx context.Context, userID int64, name, email, currentPassword, newPassword string) (*User, error) {
	if name == "" && email == "" && newPassword == "" {
		return nil, fmt.Errorf("nothing to update")
	}
	if newPassword != "" && len(newPassword) < 8 {
		return nil, errors.New("password must be at least 8 characters")
	}
	if newPassword != "" && WeakPassword(newPassword) {
		return nil, errors.New("password is too common or too simple to be allowed")
	}
	if newPassword != "" && currentPassword == "" {
		return nil, errors.New("current_password is required to change password")
	}

	user, err := s.store.UserByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if newPassword != "" {
		if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(currentPassword)); err != nil {
			return nil, ErrInvalidCredentials
		}
	}

	var namePtr, emailPtr, pwPtr *string
	if name != "" {
		namePtr = &name
	}
	if email != "" {
		emailPtr = &email
	}
	if newPassword != "" {
		hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
		if err != nil {
			return nil, err
		}
		hs := string(hash)
		pwPtr = &hs
	}
	if err := s.store.UpdateProfile(ctx, userID, namePtr, emailPtr, pwPtr); err != nil {
		return nil, err
	}
	if newPassword != "" {
		// Old sessions are invalid; the caller's own token is also revoked,
		// so the client must log in again with the new password.
		if err := s.store.DeleteTokensByUser(ctx, userID); err != nil {
			return nil, err
		}
	}
	return s.store.UserByID(ctx, userID)
}

// ponytail: fixed denylist instead of a pwned-passwords API call; swap in a
// k-anonymity HIBP check (SHA-1 prefix lookup) when network egress is acceptable.
var commonPasswords = map[string]struct{}{
	"12345678": {}, "123456789": {}, "1234567890": {}, "password": {},
	"password1": {}, "password123": {}, "qwerty123": {}, "11111111": {},
	"abc12345": {}, "iloveyou": {}, "admin123": {}, "letmein1": {},
}

// WeakPassword reports whether a new password is trivially guessable: either
// on the common-password denylist or composed of fewer than two character
// classes (e.g. all digits, all lowercase). Exported so every password-setting
// path (self profile AND admin user edits) enforces the same policy.
func WeakPassword(pw string) bool {
	lowered := strings.ToLower(pw)
	if _, bad := commonPasswords[lowered]; bad {
		return true
	}
	var classes uint8
	for _, r := range pw {
		switch {
		case unicode.IsUpper(r):
			classes |= 1
		case unicode.IsLower(r):
			classes |= 2
		case unicode.IsDigit(r):
			classes |= 4
		default:
			classes |= 8
		}
	}
	return bits.OnesCount8(classes) < 2
}

// validEmail is the shared email shape check: non-empty local part, single
// @, dot domain. Same rule the system user form enforces.
func validEmail(email string) bool {
	i := strings.IndexByte(email, '@')
	if i <= 0 || i == len(email)-1 {
		return false
	}
	return strings.Contains(email[i+1:], ".")
}

// --- password reset (B2B forgot-password) -----------------------------
// The reset link is delivered exclusively via the injected Mailer
// (WithMailer): client feedback 2026-09-10 flagged returning the token in
// the HTTP response as an account-takeover vector (leaks the token to
// anyone who can observe the response), so RequestPasswordReset's return
// value must never reach the HTTP response body — callers use it only to
// build the link for delivery.

// RequestPasswordReset issues a 1-hour reset token for the given email and
// emails the reset link. Always returns success so the endpoint cannot be
// used to enumerate accounts; the token field is empty for unknown emails.
// The returned token exists for callers that need it internally (tests) —
// the HTTP handler MUST NOT put it in the response.
func (s *Service) RequestPasswordReset(ctx context.Context, email string) (token string, err error) {
	email = strings.ToLower(strings.TrimSpace(email))
	if email == "" {
		return "", fmt.Errorf("email wajib diisi")
	}
	user, err := s.store.UserByEmail(ctx, email)
	if err != nil {
		return "", nil // unknown email: stay silent (anti-enumeration)
	}
	raw := make([]byte, 32)
	if _, err := cryptorand.Read(raw); err != nil {
		return "", err
	}
	token = strings.ToUpper(hex.EncodeToString(raw))[:32]
	if err := s.store.UpsertPasswordResetToken(ctx, email, token); err != nil {
		return "", err
	}
	if s.mailer != nil {
		link := fmt.Sprintf("%s/reset-password?token=%s", s.b2bBaseURL, token)
		subject := "Reset Kata Sandi KeluargaMart"
		body := fmt.Sprintf(
			"Halo %s,\n\nKami menerima permintaan reset kata sandi untuk akun Anda.\n"+
				"Klik tautan berikut untuk membuat kata sandi baru (berlaku 1 jam):\n\n%s\n\n"+
				"Jika Anda tidak meminta ini, abaikan email ini — kata sandi Anda tidak akan berubah.",
			user.Name, link)
		// Delivery failure must not change the response (anti-enumeration
		// stays intact regardless of mail-transport health) and services in
		// this codebase don't depend on the logger package (handler layer
		// does); best-effort send, error intentionally discarded.
		_ = s.mailer.Send(ctx, email, subject, body)
	}
	return token, nil
}

// ResetPassword consumes a reset token and sets a new password, revoking
// every existing session (same semantics as UpdateProfile).
func (s *Service) ResetPassword(ctx context.Context, token, newPassword string) error {
	if token == "" || newPassword == "" {
		return errors.New("token dan password baru wajib diisi")
	}
	if len(newPassword) < 8 {
		return errors.New("password must be at least 8 characters")
	}
	if WeakPassword(newPassword) {
		return errors.New("password is too common or too simple to be allowed")
	}
	token = strings.ToUpper(strings.TrimSpace(token))
	// Token lookup is by email row; scan all rows is overkill — tokens are
	// unique per email, so match by exact token.
	user, err := s.store.UserByResetToken(ctx, token)
	if err != nil {
		return ErrInvalidCredentials
	}
	// A deactivated account must not be reactivated through a reset; login
	// rejects inactive users, so granting a new password would be pointless
	// and confusing (and the token would outlive the deactivation).
	if !user.IsActive {
		return ErrInvalidCredentials
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	hs := string(hash)
	if err := s.store.UpdateProfile(ctx, user.ID, nil, nil, &hs); err != nil {
		return err
	}
	if err := s.store.DeleteTokensByUser(ctx, user.ID); err != nil {
		return err
	}
	return s.store.DeletePasswordResetToken(ctx, strings.ToLower(strings.TrimSpace(user.Email)))
}
