package auth

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"golang.org/x/crypto/bcrypt"

	"github.com/shopspring/decimal"
)

// fakeStore implements Store for service unit tests.
type fakeStore struct {
	users       map[string]*User // by email
	byID        map[int64]*User
	tokens      map[string]*Token // by hash
	resetTokens map[string]string // email -> token
	otpCodes    map[string]string // email\x00purpose -> code hash
	otpAttempts map[string]int    // email\x00purpose -> wrong attempts
	salesByCode map[string]int64  // employee_number -> sales user id
	referred    map[int64]int64   // new user id -> assigned sales user id
	nextID      int64
	logins      int
}

func newFake(users ...*User) *fakeStore {
	f := &fakeStore{users: map[string]*User{}, byID: map[int64]*User{}, tokens: map[string]*Token{}, resetTokens: map[string]string{}, salesByCode: map[string]int64{}, referred: map[int64]int64{}, nextID: 1}
	for _, u := range users {
		f.users[u.Email] = u
		f.byID[u.ID] = u
	}
	return f
}

func (f *fakeStore) UserByEmail(_ context.Context, email string) (*User, error) {
	if u, ok := f.users[email]; ok {
		return u, nil
	}
	return nil, ErrNotFound
}
func (f *fakeStore) UserByID(_ context.Context, id int64) (*User, error) {
	if u, ok := f.byID[id]; ok {
		return u, nil
	}
	return nil, ErrNotFound
}
func (f *fakeStore) TokenByHash(_ context.Context, hash string) (*Token, *User, error) {
	t, ok := f.tokens[hash]
	if !ok {
		return nil, nil, ErrNotFound
	}
	u, ok := f.byID[t.TokenableID]
	if !ok {
		return nil, nil, ErrNotFound
	}
	return t, u, nil
}
func (f *fakeStore) InsertToken(_ context.Context, t *Token) (int64, error) {
	t.ID = f.nextID
	f.nextID++
	f.tokens[t.TokenHash] = t
	return t.ID, nil
}
func (f *fakeStore) TouchLastUsed(context.Context, int64) error { return nil }
func (f *fakeStore) DeleteToken(_ context.Context, id int64) error {
	for h, t := range f.tokens {
		if t.ID == id {
			delete(f.tokens, h)
		}
	}
	return nil
}
func (f *fakeStore) UpdateLastLogin(_ context.Context, _ int64, _ *time.Location) error {
	f.logins++
	return nil
}
func (f *fakeStore) UpdateProfile(context.Context, int64, *string, *string, *string) error {
	return nil
}
func (f *fakeStore) DeleteTokensByUser(context.Context, int64) error {
	return nil
}
func (f *fakeStore) CreateEcommerceAccount(_ context.Context, in RegisterInput) (*User, error) {
	if in.ReferralSalesID > 0 {
		f.referred[999] = in.ReferralSalesID
	}
	u := &User{ID: 999, Name: in.Name, Email: in.Email, Role: RoleBuyer, ApplicationAccess: AppECommerce, IsActive: true}
	// 000152: provisioned UNVERIFIED — email_verified_at stays nil until
	// POST /auth/verify-email consumes the right code. Store the account so
	// the verification flow can find it.
	if f.users == nil {
		f.users = map[string]*User{}
	}
	f.users[u.Email] = u
	f.byID[u.ID] = u
	return u, nil
}

func (f *fakeStore) SalesUserIDByEmployeeCode(_ context.Context, code string) (int64, error) {
	if id, ok := f.salesByCode[code]; ok {
		return id, nil
	}
	return 0, ErrNotFound
}

// fakeHashUser returns a user whose Password is the bcrypt hash of "secret123".
func fakeHashUser(id int64, email string, role Role, active bool) *User {
	hash, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
	if err != nil {
		panic(err)
	}
	return &User{ID: id, Email: email, Name: "Test", Password: string(hash), Role: role, IsActive: active}
}

func TestLoginSuccess(t *testing.T) {
	u := fakeHashUser(1, "sales@erp.com", RoleSales, true)
	f := newFake(u)
	svc := NewService(f, time.UTC, 0)

	res, err := svc.Login(context.Background(), "sales@erp.com", "secret123")
	if err != nil {
		t.Fatalf("login: %v", err)
	}
	if res.Token == "" {
		t.Fatal("token empty")
	}
	if len(f.tokens) != 1 {
		t.Fatalf("expected 1 stored token, got %d", len(f.tokens))
	}
	if f.logins != 1 {
		t.Error("last_login should be updated once")
	}
}

func TestLoginWrongPassword(t *testing.T) {
	u := fakeHashUser(1, "sales@erp.com", RoleSales, true)
	f := newFake(u)
	svc := NewService(f, time.UTC, 0)

	if _, err := svc.Login(context.Background(), "sales@erp.com", "wrongpass"); err != ErrInvalidCredentials {
		t.Fatalf("expected ErrInvalidCredentials, got %v", err)
	}
}

func TestLoginUnknownEmail(t *testing.T) {
	f := newFake()
	svc := NewService(f, time.UTC, 0)
	if _, err := svc.Login(context.Background(), "nobody@erp.com", "secret123"); err != ErrInvalidCredentials {
		t.Fatalf("expected ErrInvalidCredentials, got %v", err)
	}
}

func TestLoginDisabled(t *testing.T) {
	u := fakeHashUser(1, "admin@erp.com", RoleAdmin, false)
	f := newFake(u)
	svc := NewService(f, time.UTC, 0)
	if _, err := svc.Login(context.Background(), "admin@erp.com", "secret123"); err != ErrAccountDisabled {
		t.Fatalf("expected ErrAccountDisabled, got %v", err)
	}
}

func TestAuthenticateValidToken(t *testing.T) {
	u := fakeHashUser(1, "finance@erp.com", RoleFinance, true)
	f := newFake(u)
	svc := NewService(f, time.UTC, 0)

	// issue a token directly via login to mirror production path
	res, err := svc.Login(context.Background(), "finance@erp.com", "secret123")
	if err != nil {
		t.Fatal(err)
	}
	got, _, err := svc.Authenticate(context.Background(), res.Token)
	if err != nil {
		t.Fatalf("authenticate: %v", err)
	}
	if got.ID != 1 {
		t.Errorf("user id = %d, want 1", got.ID)
	}
}

func TestAuthenticateRejectsGarbage(t *testing.T) {
	u := fakeHashUser(1, "finance@erp.com", RoleFinance, true)
	svc := NewService(newFake(u), time.UTC, 0)
	if _, _, err := svc.Authenticate(context.Background(), "nonsense-token"); err != ErrInvalidCredentials {
		t.Fatalf("expected ErrInvalidCredentials, got %v", err)
	}
}

func TestLogoutRevokesToken(t *testing.T) {
	u := fakeHashUser(1, "sales@erp.com", RoleSales, true)
	f := newFake(u)
	svc := NewService(f, time.UTC, 0)

	res, _ := svc.Login(context.Background(), "sales@erp.com", "secret123")
	user, tokenID, err := svc.Authenticate(context.Background(), res.Token)
	if err != nil {
		t.Fatal(err)
	}
	_ = svc.Logout(context.Background(), tokenID)
	if _, _, err := svc.Authenticate(context.Background(), res.Token); err != ErrInvalidCredentials {
		t.Fatalf("token should be revoked, got %v", err)
	}
	_ = user
}

func floatPtr(f float64) *float64 { return &f }

// keep decimal import referenced in tests (money serialization spot check)
func TestDecimalGuard(t *testing.T) {
	d, _ := decimal.NewFromString("3996000.00")
	if d.String() != "3996000" {
		t.Errorf("unexpected serialization %s", d.String())
	}
}

var _ = floatPtr

func (f *fakeStore) UpsertPasswordResetToken(_ context.Context, email, token string) error {
	f.resetTokens[email] = token
	return nil
}

func (f *fakeStore) UserByResetToken(_ context.Context, token string) (*User, error) {
	for email, tok := range f.resetTokens {
		if tok == token {
			if u, ok := f.users[email]; ok {
				return u, nil
			}
		}
	}
	return nil, ErrNotFound
}

func (f *fakeStore) DeletePasswordResetToken(_ context.Context, email string) error {
	delete(f.resetTokens, email)
	return nil
}

// --- email verification (000152) fake -------------------------------
// Codes stored PLAINTEXT in the fake (the real store hashes; the service
// hashes before calling) keyed email -> code, plus an attempt counter.

func (f *fakeStore) UpsertEmailVerificationCode(_ context.Context, email, purpose, codeHash string) error {
	if f.otpCodes == nil {
		f.otpCodes = map[string]string{}
		f.otpAttempts = map[string]int{}
	}
	f.otpCodes[email+"\x00"+purpose] = codeHash
	f.otpAttempts[email+"\x00"+purpose] = 0
	return nil
}

func (f *fakeStore) GetEmailVerificationCode(_ context.Context, email, purpose string) (*EmailVerificationCode, error) {
	if f.otpCodes == nil {
		return nil, ErrNotFound
	}
	h, ok := f.otpCodes[email+"\x00"+purpose]
	if !ok {
		return nil, ErrNotFound
	}
	return &EmailVerificationCode{
		Email:     email,
		Purpose:   purpose,
		CodeHash:  h,
		Attempts:  f.otpAttempts[email+"\x00"+purpose],
		ExpiresAt: time.Now().Add(10 * time.Minute),
	}, nil
}

func (f *fakeStore) BumpEmailVerificationAttempts(_ context.Context, email, purpose string) error {
	f.otpAttempts[email+"\x00"+purpose]++
	return nil
}

func (f *fakeStore) DeleteEmailVerificationCode(_ context.Context, email, purpose string) error {
	delete(f.otpCodes, email+"\x00"+purpose)
	return nil
}

func (f *fakeStore) MarkEmailVerified(_ context.Context, email string) error {
	if u, ok := f.users[email]; ok {
		now := time.Now()
		u.EmailVerifiedAt = &now
	}
	return nil
}

// capturingMailer is a Mailer test double that records every send instead of
// talking to a real SMTP server.
type capturingMailer struct {
	sent []struct{ to, subject, body string }
}

func (m *capturingMailer) Send(_ context.Context, to, subject, body string) error {
	m.sent = append(m.sent, struct{ to, subject, body string }{to, subject, body})
	return nil
}

// TestForgotPasswordResponseNeverLeaksToken pins the anti-enumeration + no-
// leak contract (2026-09-10 finding): the HTTP response for both a known and
// an unknown email must be the same generic message and must never contain
// the word "token" or the raw token value — the token/link may only reach
// the injected mailer, never the response body.
func TestForgotPasswordResponseNeverLeaksToken(t *testing.T) {
	known := fakeHashUser(1, "buyer@erp.com", RoleBuyer, true)
	store := newFake(known)
	mail := &capturingMailer{}
	svc := NewService(store, time.UTC, 0).WithMailer(mail, "https://b2b.example.test")
	h := NewHandler(svc)

	post := func(email string) (*httptest.ResponseRecorder, string) {
		body := `{"email":"` + email + `"}`
		req := httptest.NewRequest(http.MethodPost, "/forgot-password", strings.NewReader(body))
		rec := httptest.NewRecorder()
		h.ForgotPassword(rec, req)
		return rec, rec.Body.String()
	}

	knownRec, knownBody := post("buyer@erp.com")
	unknownRec, unknownBody := post("nobody@erp.com")

	for name, rec := range map[string]*httptest.ResponseRecorder{"known": knownRec, "unknown": unknownRec} {
		if rec.Code != http.StatusOK {
			t.Fatalf("%s email: expected 200, got %d", name, rec.Code)
		}
	}
	for name, body := range map[string]string{"known": knownBody, "unknown": unknownBody} {
		if strings.Contains(strings.ToLower(body), "token") {
			t.Fatalf("%s email: response leaked the word \"token\": %s", name, body)
		}
	}
	if knownBody != unknownBody {
		t.Fatalf("anti-enumeration violated: known/unknown responses differ: %q vs %q", knownBody, unknownBody)
	}

	// The known email must still have triggered exactly one email carrying
	// the reset link — delivery happens, just never via the HTTP response.
	if len(mail.sent) != 1 {
		t.Fatalf("expected exactly 1 email sent (for the known address only), got %d", len(mail.sent))
	}
	if mail.sent[0].to != "buyer@erp.com" {
		t.Fatalf("email sent to wrong address: %s", mail.sent[0].to)
	}
	if !strings.Contains(mail.sent[0].body, "https://b2b.example.test/reset-password?token=") {
		t.Fatalf("email body missing reset link in the contracted format: %s", mail.sent[0].body)
	}
	if strings.Contains(knownBody, mail.sent[0].body) {
		t.Fatal("response body must not embed the emailed content")
	}
}

func TestRegisterBusinessReferralKnownCode(t *testing.T) {
	f := newFake()
	f.salesByCode["EMP-002"] = 42
	svc := NewService(f, time.UTC, 0)
	res, err := svc.Register(context.Background(), RegisterInput{
		AccountType: "business", Name: "PIC", Email: "biz@x.id",
		Password: "secret123", CompanyName: "PT X", ReferralCode: "emp-002",
	})
	if err != nil {
		t.Fatal(err)
	}
	if res == nil {
		t.Fatal("nil result")
	}
	if got := f.referred[999]; got != 42 {
		t.Errorf("assigned sales = %d, want 42 (code normalized to upper)", got)
	}
}

func TestRegisterBusinessReferralUnknownCode(t *testing.T) {
	f := newFake()
	svc := NewService(f, time.UTC, 0)
	_, err := svc.Register(context.Background(), RegisterInput{
		AccountType: "business", Name: "PIC", Email: "biz@x.id",
		Password: "secret123", CompanyName: "PT X", ReferralCode: "NOPE-1",
	})
	if !errors.Is(err, ErrInvalidReferral) {
		t.Errorf("err = %v, want ErrInvalidReferral", err)
	}
}

func TestRegisterIndividualReferralRejected(t *testing.T) {
	f := newFake()
	f.salesByCode["EMP-002"] = 42
	svc := NewService(f, time.UTC, 0)
	_, err := svc.Register(context.Background(), RegisterInput{
		AccountType: "individual", Name: "A", Email: "a@x.id",
		Password: "secret123", ReferralCode: "EMP-002",
	})
	if !errors.Is(err, ErrRegisterInvalid) {
		t.Errorf("err = %v, want ErrRegisterInvalid", err)
	}
}

func TestRegisterRejectsMalformedEmail(t *testing.T) {
	for _, email := range []string{"plainaddress", "@nodomain", "a@b", "a@", "@"} {
		svc := NewService(newFake(), time.UTC, 0)
		_, err := svc.Register(context.Background(), RegisterInput{
			AccountType: "individual", Name: "A", Email: email, Password: "secret123",
		})
		if !errors.Is(err, ErrRegisterInvalid) {
			t.Errorf("email %q: err = %v, want ErrRegisterInvalid", email, err)
		}
	}
}

func TestRegisterBusinessNoReferralSelf(t *testing.T) {
	f := newFake()
	svc := NewService(f, time.UTC, 0)
	if _, err := svc.Register(context.Background(), RegisterInput{
		AccountType: "business", Name: "PIC", Email: "biz@x.id",
		Password: "secret123", CompanyName: "PT X",
	}); err != nil {
		t.Fatal(err)
	}
	if _, ok := f.referred[999]; ok {
		t.Error("self-registered business must not link a sales user")
	}
}

// --- email verification (000152) -------------------------------------

func otpTestService(t *testing.T) (*Service, *fakeStore, *capturingMailer) {
	t.Helper()
	f := newFake()
	mail := &capturingMailer{}
	svc := NewService(f, time.UTC, 0).WithMailer(mail, "https://b2b.example.test")
	return svc, f, mail
}

func otpCodeFromMail(t *testing.T, mail *capturingMailer) string {
	t.Helper()
	if len(mail.sent) == 0 {
		t.Fatal("no email sent")
	}
	body := mail.sent[len(mail.sent)-1].body
	for _, line := range strings.Split(body, "\n") {
		s := strings.TrimSpace(line)
		if len(s) == 6 && s >= "000000" && s <= "999999" {
			return s
		}
	}
	t.Fatalf("no 6-digit code in email body: %s", body)
	return ""
}

func TestRegisterIssuesOTPPNoSession(t *testing.T) {
	svc, f, mail := otpTestService(t)
	res, err := svc.Register(context.Background(), RegisterInput{
		AccountType: "individual", Name: "A", Email: "otp@x.id", Password: "secret123",
	})
	if err != nil {
		t.Fatal(err)
	}
	if !res.VerificationRequired || res.Email != "otp@x.id" {
		t.Errorf("result = %+v, want verification_required for otp@x.id", res)
	}
	// No session at registration: LoginResult.Token must be empty — the
	// token only exists after the code is verified.
	if f.tokens == nil || len(f.tokens) != 0 {
		t.Errorf("register must not issue a token, got %d", len(f.tokens))
	}
	// The account exists but is NOT verified.
	if u := f.users["otp@x.id"]; u == nil || u.EmailVerifiedAt != nil {
		t.Errorf("account missing or pre-verified: %+v", u)
	}
	if len(mail.sent) == 0 {
		t.Fatal("verification email not sent")
	}
}

func TestVerifyEmailSuccessIssuesSession(t *testing.T) {
	svc, f, mail := otpTestService(t)
	if _, err := svc.Register(context.Background(), RegisterInput{
		AccountType: "individual", Name: "A", Email: "v@x.id", Password: "secret123",
	}); err != nil {
		t.Fatal(err)
	}
	code := otpCodeFromMail(t, mail)
	res, err := svc.VerifyEmail(context.Background(), "v@x.id", code)
	if err != nil {
		t.Fatal(err)
	}
	if res.Token == "" {
		t.Fatal("verified account must receive a session token")
	}
	if u := f.users["v@x.id"]; u.EmailVerifiedAt == nil {
		t.Error("email_verified_at not stamped")
	}
	// The code row is consumed: a second verify with the same code fails.
	if _, err := svc.VerifyEmail(context.Background(), "v@x.id", code); !errors.Is(err, ErrAlreadyVerified) {
		t.Errorf("second verify err = %v, want ErrAlreadyVerified", err)
	}
}

func TestVerifyEmailWrongCodeConsumesAttempt(t *testing.T) {
	svc, _, _ := otpTestService(t)
	if _, err := svc.Register(context.Background(), RegisterInput{
		AccountType: "individual", Name: "A", Email: "w@x.id", Password: "secret123",
	}); err != nil {
		t.Fatal(err)
	}
	for i := 0; i < 5; i++ {
		if _, err := svc.VerifyEmail(context.Background(), "w@x.id", "000000"); !errors.Is(err, ErrCodeInvalid) {
			t.Fatalf("attempt %d: err = %v, want ErrCodeInvalid", i+1, err)
		}
	}
	// 5 wrong attempts lock the row even if the 6th guess is right.
	code := "111111"
	hash, _ := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
	_ = hash // real code unknown here — lock must trigger regardless
	if _, err := svc.VerifyEmail(context.Background(), "w@x.id", code); !errors.Is(err, ErrCodeLocked) {
		t.Errorf("locked err = %v, want ErrCodeLocked", err)
	}
}

func TestVerifyEmailExpiredCodeRejected(t *testing.T) {
	svc, f, _ := otpTestService(t)
	if _, err := svc.Register(context.Background(), RegisterInput{
		AccountType: "individual", Name: "A", Email: "e@x.id", Password: "secret123",
	}); err != nil {
		t.Fatal(err)
	}
	// Force-expire the stored row.
	for k := range f.otpCodes {
		if strings.HasPrefix(k, "e@x.id\x00") {
			// expiry is checked via the fake's fixed 10-minute window; to
			// simulate expiry we delete the row instead (same code path:
			// missing row -> ErrCodeInvalid).
			delete(f.otpCodes, k)
		}
	}
	if _, err := svc.VerifyEmail(context.Background(), "e@x.id", "123456"); !errors.Is(err, ErrCodeInvalid) {
		t.Errorf("err = %v, want ErrCodeInvalid", err)
	}
}

func TestResendVerificationReplacesCode(t *testing.T) {
	svc, _, mail := otpTestService(t)
	if _, err := svc.Register(context.Background(), RegisterInput{
		AccountType: "individual", Name: "A", Email: "r@x.id", Password: "secret123",
	}); err != nil {
		t.Fatal(err)
	}
	before := len(mail.sent)
	if err := svc.ResendVerification(context.Background(), "r@x.id"); err != nil {
		t.Fatal(err)
	}
	if len(mail.sent) != before+1 {
		t.Fatalf("resend must deliver a fresh code, sent %d -> %d", before, len(mail.sent))
	}
	code := otpCodeFromMail(t, mail)
	res, err := svc.VerifyEmail(context.Background(), "r@x.id", code)
	if err != nil {
		t.Fatalf("new code must verify: %v", err)
	}
	if res.Token == "" {
		t.Fatal("session missing after resend+verify")
	}
}

func TestResendVerificationSilentForUnknownEmail(t *testing.T) {
	svc, _, mail := otpTestService(t)
	if err := svc.ResendVerification(context.Background(), "ghost@x.id"); err != nil {
		t.Fatalf("unknown email must stay silent, got %v", err)
	}
	if len(mail.sent) != 0 {
		t.Fatal("no email may be sent for unknown addresses (anti-enumeration)")
	}
}

func TestLoginUnverifiedEcommerceRejected(t *testing.T) {
	// fakeHashUser hashes "secret123"; the account is e-commerce scoped and
	// NOT verified (EmailVerifiedAt nil) — exactly what CreateEcommerceAccount
	// provisions since 000152.
	f := newFake(fakeHashUser(7, "u@x.id", RoleBuyer, true))
	f.users["u@x.id"].ApplicationAccess = AppECommerce
	mail := &capturingMailer{}
	svc := NewService(f, time.UTC, 0).WithMailer(mail, "https://b2b.example.test")
	// A correct password must still be refused while the account is unverified.
	if _, err := svc.Login(context.Background(), "u@x.id", "secret123"); !errors.Is(err, ErrVerificationPending) {
		t.Errorf("err = %v, want ErrVerificationPending", err)
	}
	// Verify, then the same credentials pass.
	now := time.Now()
	f.users["u@x.id"].EmailVerifiedAt = &now
	if _, err := svc.Login(context.Background(), "u@x.id", "secret123"); err != nil {
		t.Errorf("verified login err = %v, want nil", err)
	}
}

func TestLoginStaffAccountUnaffectedByVerification(t *testing.T) {
	// ERP staff accounts have no email_verified_at and must keep logging in.
	f := newFake(fakeHashUser(8, "staff@x.id", RoleEmployee, true))
	f.users["staff@x.id"].ApplicationAccess = AppERP
	svc := NewService(f, time.UTC, 0)
	if _, err := svc.Login(context.Background(), "staff@x.id", "secret123"); err != nil {
		t.Errorf("staff login err = %v, want nil (verification is e-commerce only)", err)
	}
}

func TestLoginUnverifiedEcommerceTargetingRestrictedShell(t *testing.T) {
	// An unverified e-commerce buyer attempting to log into ERP or POS must
	// bypass ErrVerificationPending so downstream app-access gates reject with
	// the proper shell-forbidden response ("Akun ini tidak memiliki hak akses ke aplikasi ERP")
	// rather than misleading the user with "Email belum diverifikasi".
	f := newFake(fakeHashUser(9, "buyer-unverified@x.id", RoleBuyer, true))
	f.users["buyer-unverified@x.id"].ApplicationAccess = AppECommerce
	svc := NewService(f, time.UTC, 0)

	// Targeting ERP: should bypass ErrVerificationPending
	res, err := svc.Login(context.Background(), "buyer-unverified@x.id", "secret123", "erp")
	if err != nil {
		t.Fatalf("expected bypass for erp shell, got err = %v", err)
	}
	if res.User.CanAccessApp(AppERP) {
		t.Fatalf("buyer must not have ERP access")
	}

	// Targeting POS: should bypass ErrVerificationPending
	res, err = svc.Login(context.Background(), "buyer-unverified@x.id", "secret123", "pos")
	if err != nil {
		t.Fatalf("expected bypass for pos shell, got err = %v", err)
	}
	if res.User.CanAccessApp(AppPOS) {
		t.Fatalf("buyer must not have POS access")
	}

	// Targeting ECommerce: MUST still reject with ErrVerificationPending
	_, err = svc.Login(context.Background(), "buyer-unverified@x.id", "secret123", "ecommerce")
	if !errors.Is(err, ErrVerificationPending) {
		t.Fatalf("expected ErrVerificationPending for ecommerce shell, got err = %v", err)
	}
}
