package partners

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"
	"golang.org/x/crypto/bcrypt"
)

// Sentinel errors.
var (
	ErrNotFound      = errors.New("not found")
	ErrDuplicateCode = errors.New("code already exists")
	// ErrValidation is a request the caller can fix. Kept distinct from a bare
	// error so respondErr can answer 400 deliberately instead of relying on its
	// fallthrough arm, and so tests can assert the rejection without matching on
	// message text.
	ErrValidation = errors.New("validation failed")
)

// Seq allocates atomic counters (like catalog.Seq).
type Seq interface {
	Sequence(ctx context.Context, prefix string, date time.Time, tx any) (int64, error)
}

// Service implements suppliers/customers use cases.
type Service struct {
	store Store
	seq   Seq
	loc   *time.Location
	// onCustomerDeactivated runs after a customer is deactivated or
	// soft-deleted (R2-6a): the linked login must lose access too, since
	// per-request auth only checks users.is_active. Nil = feature off.
	onCustomerDeactivated func(ctx context.Context, customerID int64) error
	// onCustomerPasswordReset runs after an admin resets a customer's login
	// password: revokes the linked user's tokens. Nil = feature off.
	onCustomerPasswordReset func(ctx context.Context, userID int64) error
}

// WithOnCustomerDeactivated wires the login-sync hook (main.go adapts the
// system/auth repositories: deactivate linked user + revoke its tokens).
func (s *Service) WithOnCustomerDeactivated(fn func(ctx context.Context, customerID int64) error) *Service {
	if fn != nil {
		s.onCustomerDeactivated = fn
	}
	return s
}

// onCustomerPasswordReset performs the login-sync half of an admin password
// reset: revoke every bearer token of the linked user so a stolen session
// cannot outlive the credential change. main.go adapts the auth repository.
func (s *Service) WithOnCustomerPasswordReset(fn func(ctx context.Context, userID int64) error) *Service {
	if fn != nil {
		s.onCustomerPasswordReset = fn
	}
	return s
}

func NewService(store Store, seq Seq, loc *time.Location) *Service {
	return &Service{store: store, seq: seq, loc: loc}
}

// --- suppliers ---------------------------------------------------------------

func (s *Service) ListSuppliers(ctx context.Context, f ListFilter) ([]SupplierView, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	if f.Status != "" && f.Status != "active" && f.Status != "inactive" {
		return nil, 0, errors.New("status must be active or inactive")
	}
	rows, total, err := s.store.ListSuppliers(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]SupplierView, 0, len(rows))
	for _, r := range rows {
		out = append(out, supplierView(db.GetSupplierByIDRow(r)))
	}
	return out, total, nil
}

func (s *Service) SupplierByID(ctx context.Context, id int64) (*SupplierView, error) {
	r, err := s.store.SupplierByID(ctx, id)
	if err != nil {
		return nil, err
	}
	v := supplierView(r)
	return &v, nil
}

func (s *Service) CreateSupplier(ctx context.Context, in SupplierInput) (*SupplierView, error) {
	if strings.TrimSpace(in.Name) == "" {
		return nil, errors.New("name is required")
	}
	if in.PaymentTerms <= 0 {
		in.PaymentTerms = 30
	}
	code, err := s.nextCode(ctx, "SUP")
	if err != nil {
		return nil, err
	}
	id, err := s.store.CreateSupplier(ctx, db.CreateSupplierParams{
		Code:          code,
		Name:          strings.TrimSpace(in.Name),
		ContactPerson: pgtext(in.ContactPerson),
		Phone:         pgtext(in.Phone),
		Email:         pgtext(in.Email),
		Address:       pgtext(in.Address),
		City:          pgtext(in.City),
		TaxNumber:     pgtext(in.TaxNumber),
		BankName:      pgtext(in.BankName),
		BankAccount:   pgtext(in.BankAccount),
		CreditLimit:   in.CreditLimit.Round(2),
		PaymentTerms:  int32(in.PaymentTerms),
		IsActive:      in.IsActive,
	})
	if err != nil {
		return nil, err
	}
	return &SupplierView{
		ID:            id,
		Code:          code,
		Name:          strings.TrimSpace(in.Name),
		ContactPerson: in.ContactPerson,
		Phone:         in.Phone,
		Email:         in.Email,
		Address:       in.Address,
		City:          in.City,
		TaxNumber:     in.TaxNumber,
		BankName:      in.BankName,
		BankAccount:   in.BankAccount,
		CreditLimit:   in.CreditLimit.Round(2),
		PaymentTerms:  in.PaymentTerms,
		IsActive:      in.IsActive,
	}, nil
}

func (s *Service) UpdateSupplier(ctx context.Context, id int64, in SupplierInput) (*SupplierView, error) {
	existing, err := s.store.SupplierByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if strings.TrimSpace(in.Name) == "" {
		return nil, errors.New("name is required")
	}
	if in.PaymentTerms <= 0 {
		in.PaymentTerms = 30
	}
	err = s.store.UpdateSupplier(ctx, db.UpdateSupplierParams{
		ID:            id,
		Name:          strings.TrimSpace(in.Name),
		ContactPerson: pgtext(in.ContactPerson),
		Phone:         pgtext(in.Phone),
		Email:         pgtext(in.Email),
		Address:       pgtext(in.Address),
		City:          pgtext(in.City),
		TaxNumber:     pgtext(in.TaxNumber),
		BankName:      pgtext(in.BankName),
		BankAccount:   pgtext(in.BankAccount),
		CreditLimit:   in.CreditLimit.Round(2),
		PaymentTerms:  int32(in.PaymentTerms),
		IsActive:      in.IsActive,
	})
	if err != nil {
		return nil, err
	}
	_ = existing
	return s.SupplierByID(ctx, id)
}

func (s *Service) DeleteSupplier(ctx context.Context, id int64) error {
	return s.store.DeleteSupplier(ctx, id)
}

// --- customers ---------------------------------------------------------------

func (s *Service) ListCustomers(ctx context.Context, f ListFilter) ([]CustomerView, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	// Cap mirrors ListStock/catalog (1000), not 20: POS pulls the whole
	// customer book with per_page=1000 for its picker, and a silent reset
	// stranded new customers on an unreachable page 2.
	if f.PerPage < 1 || f.PerPage > 1000 {
		f.PerPage = 20
	}
	if f.Status != "" && f.Status != "active" && f.Status != "inactive" {
		return nil, 0, errors.New("status must be active or inactive")
	}
	rows, total, err := s.store.ListCustomers(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]CustomerView, 0, len(rows))
	for _, r := range rows {
		out = append(out, customerView(db.GetCustomerByIDRow(r)))
	}
	return out, total, nil
}

func (s *Service) CustomerByID(ctx context.Context, id int64) (*CustomerView, error) {
	r, err := s.store.CustomerByID(ctx, id)
	if err != nil {
		return nil, err
	}
	v := customerView(r)
	return &v, nil
}

func (s *Service) CreateCustomer(ctx context.Context, in CustomerInput) (*CustomerView, error) {
	if strings.TrimSpace(in.Name) == "" {
		return nil, errors.New("name is required")
	}
	if in.CustomerType == "" {
		in.CustomerType = "retail"
	}
	if !validType(in.CustomerType) {
		return nil, errors.New("customer_type must be one of retail, wholesale, distributor")
	}
	if in.CreditLimit.IsNegative() {
		return nil, errors.New("credit_limit must not be negative")
	}
	code, err := s.nextCode(ctx, "CUS")
	if err != nil {
		return nil, err
	}
	id, err := s.store.CreateCustomer(ctx, db.CreateCustomerParams{
		Code:            code,
		Name:            strings.TrimSpace(in.Name),
		ContactPerson:   pgtext(in.ContactPerson),
		Phone:           pgtext(in.Phone),
		Email:           pgtext(in.Email),
		Address:         pgtext(in.Address),
		City:            pgtext(in.City),
		Kecamatan:       pgtext(in.Kecamatan),
		Kelurahan:       pgtext(in.Kelurahan),
		Desa:            pgtext(in.Desa),
		TaxNumber:       pgtext(in.TaxNumber),
		CreditLimit:     in.CreditLimit.Round(2),
		PaymentTerms:    int32(in.PaymentTerms),
		CustomerType:    in.CustomerType,
		AssignedSalesID: int8Ptr(in.AssignedSalesID),
		IsActive:        in.IsActive,
	})
	if err != nil {
		return nil, err
	}
	return &CustomerView{
		ID:              id,
		Code:            code,
		Name:            strings.TrimSpace(in.Name),
		ContactPerson:   in.ContactPerson,
		Phone:           in.Phone,
		Email:           in.Email,
		Address:         in.Address,
		City:            in.City,
		Kecamatan:       in.Kecamatan,
		Kelurahan:       in.Kelurahan,
		Desa:            in.Desa,
		TaxNumber:       in.TaxNumber,
		CreditLimit:     in.CreditLimit.Round(2),
		PaymentTerms:    in.PaymentTerms,
		CustomerType:    in.CustomerType,
		AssignedSalesID: in.AssignedSalesID,
		IsActive:        in.IsActive,
	}, nil
}

func (s *Service) UpdateCustomer(ctx context.Context, id int64, in CustomerInput) (*CustomerView, error) {
	existing, err := s.store.CustomerByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if strings.TrimSpace(in.Name) == "" {
		return nil, errors.New("name is required")
	}
	if in.CustomerType != "" && !validType(in.CustomerType) {
		return nil, errors.New("customer_type must be one of retail, wholesale, distributor")
	}
	if in.CreditLimit.IsNegative() {
		return nil, errors.New("credit_limit must not be negative")
	}
	if in.CustomerType == "" {
		in.CustomerType = existing.CustomerType
	}
	err = s.store.UpdateCustomer(ctx, db.UpdateCustomerParams{
		ID:              id,
		Name:            strings.TrimSpace(in.Name),
		ContactPerson:   pgtext(in.ContactPerson),
		Phone:           pgtext(in.Phone),
		Kecamatan:       pgtext(in.Kecamatan),
		Kelurahan:       pgtext(in.Kelurahan),
		Desa:            pgtext(in.Desa),
		Email:           pgtext(in.Email),
		Address:         pgtext(in.Address),
		City:            pgtext(in.City),
		TaxNumber:       pgtext(in.TaxNumber),
		CreditLimit:     in.CreditLimit.Round(2),
		PaymentTerms:    int32(in.PaymentTerms),
		CustomerType:    in.CustomerType,
		AssignedSalesID: int8Ptr(in.AssignedSalesID),
		IsActive:        in.IsActive,
	})
	if err != nil {
		return nil, err
	}
	if existing.IsActive && !in.IsActive && s.onCustomerDeactivated != nil {
		if err := s.onCustomerDeactivated(ctx, id); err != nil {
			return nil, err
		}
	}
	_ = existing
	return s.CustomerByID(ctx, id)
}

func (s *Service) DeleteCustomer(ctx context.Context, id int64) error {
	if err := s.store.DeleteCustomer(ctx, id); err != nil {
		return err
	}
	if s.onCustomerDeactivated != nil {
		if err := s.onCustomerDeactivated(ctx, id); err != nil {
			return err
		}
	}
	return nil
}

// ResetCustomerPassword lets an ERP operator set a fresh login password for
// a customer's B2B account — the recovery path when a buyer is locked out
// ("email atau password salah" with no working credential). The customer
// MUST have a linked login (customers.user_id): a customer without one has
// nothing to reset. The hash is written directly (no current-password check
// — the operator is the authenticated authority here) and every bearer
// token of the user is revoked so an existing session cannot outlive the
// change. The new password follows the same strength rules as registration.
func (s *Service) ResetCustomerPassword(ctx context.Context, id int64, newPassword string) error {
	// Match auth.Register's rules (min length + common-password list) so an
	// operator cannot set a credential the login would effectively refuse.
	if len(newPassword) < 8 {
		return errors.New("password must be at least 8 characters")
	}
	if auth.WeakPassword(newPassword) {
		return errors.New("password is too common or too simple to be allowed")
	}
	// Resolve the linked login through one statement: customers.user_id,
	// NULL when the customer has no account (nothing to reset).
	row, err := s.store.CustomerLoginUser(ctx, id)
	if err != nil {
		return ErrNotFound
	}
	if !row.IsActive {
		return errors.New("akun login customer ini sedang nonaktif — aktifkan dulu sebelum reset password")
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
	if err != nil {
		return err
	}
	if err := s.store.SetUserPassword(ctx, row.ID, string(hash)); err != nil {
		return err
	}
	if s.onCustomerPasswordReset != nil {
		if err := s.onCustomerPasswordReset(ctx, row.ID); err != nil {
			return err
		}
	}
	return nil
}

// --- view helpers ------------------------------------------------------------

func supplierView(r db.GetSupplierByIDRow) SupplierView {
	return SupplierView{
		ID:            r.ID,
		Code:          r.Code,
		Name:          r.Name,
		ContactPerson: orEmpty(r.ContactPerson),
		Phone:         orEmpty(r.Phone),
		Email:         orEmpty(r.Email),
		Address:       orEmpty(r.Address),
		City:          orEmpty(r.City),
		TaxNumber:     orEmpty(r.TaxNumber),
		BankName:      orEmpty(r.BankName),
		BankAccount:   orEmpty(r.BankAccount),
		CreditLimit:   r.CreditLimit,
		PaymentTerms:  int(r.PaymentTerms),
		IsActive:      r.IsActive,
		DeletedAt:     tsPtr(r.DeletedAt),
	}
}

func customerView(r db.GetCustomerByIDRow) CustomerView {
	return CustomerView{
		ID:              r.ID,
		Code:            r.Code,
		Name:            r.Name,
		ContactPerson:   orEmpty(r.ContactPerson),
		Phone:           orEmpty(r.Phone),
		Email:           orEmpty(r.Email),
		Address:         orEmpty(r.Address),
		City:            orEmpty(r.City),
		Kecamatan:       orEmpty(r.Kecamatan),
		Kelurahan:       orEmpty(r.Kelurahan),
		Desa:            orEmpty(r.Desa),
		TaxNumber:       orEmpty(r.TaxNumber),
		CreditLimit:     r.CreditLimit,
		PaymentTerms:    int(r.PaymentTerms),
		CustomerType:    r.CustomerType,
		AssignedSalesID: int8PtrFrom(r.AssignedSalesID),
		IsActive:        r.IsActive,
		DeletedAt:       tsPtr(r.DeletedAt),
	}
}

func orEmpty(t pgtype.Text) string {
	if !t.Valid {
		return ""
	}
	return t.String
}

func pgtext(s string) pgtype.Text {
	return pgtype.Text{String: s, Valid: true}
}

func int8PtrFrom(i pgtype.Int8) *int64 {
	if !i.Valid {
		return nil
	}
	v := i.Int64
	return &v
}

func tsPtr(t pgtype.Timestamptz) *time.Time {
	if !t.Valid {
		return nil
	}
	v := t.Time
	return &v
}

// globalAnchorDate ensures non-date-prefixed codes (CUS-xxx, SUP-xxx)
// never reset daily and remain monotonically increasing.
var globalAnchorDate = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)

func (s *Service) nextCode(ctx context.Context, prefix string) (string, error) {
	for attempts := 0; attempts < 100; attempts++ {
		n, err := s.seq.Sequence(ctx, prefix, globalAnchorDate, nil)
		if err != nil {
			return "", fmt.Errorf("%s code sequence: %w", prefix, err)
		}
		candidate := fmt.Sprintf("%s-%03d", prefix, n)

		// Verify candidate does not collide with existing seeded or manually added records
		var exists bool
		if prefix == "CUS" {
			_, err := s.store.CustomerByCode(ctx, candidate)
			exists = err == nil
		} else if prefix == "SUP" {
			_, err := s.store.SupplierByCode(ctx, candidate)
			exists = err == nil
		}

		if !exists {
			return candidate, nil
		}
	}
	return "", fmt.Errorf("failed to allocate unique %s code after 100 attempts", prefix)
}

func validType(t string) bool {
	switch t {
	case "retail", "wholesale", "distributor":
		return true
	}
	return false
}

// MyProfile returns the signed-in caller's own customer record, or
// ErrNotFound when the account has no customer row behind it.
//
// It is keyed by users.id rather than an id in the path on purpose. The
// company directory lives behind an internal PII gate precisely so a
// storefront buyer cannot enumerate it; a "get by id" profile route would
// undo that while looking like a convenience.
func (s *Service) MyProfile(ctx context.Context, userID int64) (ProfileView, error) {
	row, err := s.store.CustomerProfileForUser(ctx, userID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return ProfileView{}, ErrNotFound
		}
		return ProfileView{}, err
	}
	return ProfileView{
		ID:                row.ID,
		Code:              row.Code,
		Name:              row.Name,
		ContactPerson:     row.ContactPerson,
		Phone:             row.Phone,
		Email:             row.Email,
		Address:           row.Address,
		City:              row.City,
		Kecamatan:         row.Kecamatan,
		Kelurahan:         row.Kelurahan,
		Desa:              row.Desa,
		TaxNumber:         row.TaxNumber,
		Website:           row.Website,
		BillingAddressID:  row.BillingAddressID,
		ShippingAddressID: row.ShippingAddressID,
	}, nil
}

// UpdateMyProfile writes the caller's own company profile. Same scoping rule as
// MyProfile: the target is derived from the token, so there is no id to tamper
// with.
//
// A nil field means "leave it alone"; a present field is the new value, and a
// pointer to "" clears it. See ProfileInput for why that distinction needs
// pointer fields and cannot be had with plain strings.
//
// A blank name is rejected rather than silently ignored. It is the one field
// with no sensible empty state — a company directory entry with no name is not
// a record anybody can use — and answering a client that sent {"name":""} with
// a 200 and an unchanged value teaches the caller that the field is read-only.
func (s *Service) UpdateMyProfile(ctx context.Context, userID int64, in ProfileInput) (ProfileView, error) {
	if in.Name != nil && strings.TrimSpace(*in.Name) == "" {
		return ProfileView{}, fmt.Errorf("%w: name tidak boleh kosong", ErrValidation)
	}
	p := db.UpdateCustomerProfileForUserParams{
		Name:              textPtr(in.Name),
		ContactPerson:     textPtr(in.ContactPerson),
		Phone:             textPtr(in.Phone),
		Email:             textPtr(in.Email),
		Address:           textPtr(in.Address),
		City:              textPtr(in.City),
		Kecamatan:         textPtr(in.Kecamatan),
		Kelurahan:         textPtr(in.Kelurahan),
		Desa:              textPtr(in.Desa),
		TaxNumber:         textPtr(in.TaxNumber),
		Website:           textPtr(in.Website),
		BillingSet:        in.BillingAddressID.Present,
		BillingAddressID:  nullInt8(in.BillingAddressID),
		ShippingSet:       in.ShippingAddressID.Present,
		ShippingAddressID: nullInt8(in.ShippingAddressID),
		UserID:            pgtype.Int8{Int64: userID, Valid: true},
	}
	if _, err := s.store.UpdateCustomerProfileForUser(ctx, p); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return ProfileView{}, ErrNotFound
		}
		return ProfileView{}, err
	}
	return s.MyProfile(ctx, userID)
}

// textPtr maps *string to pgtype.Text, where a nil pointer is an invalid
// (NULL) value so the COALESCE in the UPDATE leaves the column untouched.
func textPtr(s *string) pgtype.Text {
	if s == nil {
		return pgtype.Text{}
	}
	return pgtype.Text{String: *s, Valid: true}
}

// int8Ptr maps *int64 to pgtype.Int8 for the admin create/update path, where a
// nil pointer means NULL. That path takes plain non-pointer-shaped input, so
// absent and null are the same request there and no *Set flag is needed.
func int8Ptr(i *int64) pgtype.Int8 {
	if i == nil {
		return pgtype.Int8{}
	}
	return pgtype.Int8{Int64: *i, Valid: true}
}

// nullInt8 maps a NullInt to pgtype.Int8. An absent field yields an invalid
// value, but the caller must also clear the paired *Set flag, because for these
// two columns an invalid value means "write NULL", not "leave it alone" — the
// whole reason the flag exists.
func nullInt8(n NullInt) pgtype.Int8 {
	if !n.Valid {
		return pgtype.Int8{}
	}
	return pgtype.Int8{Int64: n.Value, Valid: true}
}
