package catalog

import (
	"context"
	"errors"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/shopspring/decimal"
)

type fakeStore struct {
	categories   map[int64]db.GetCategoryByIDRow
	units        map[int64]db.GetUnitByIDRow
	products     map[int64]db.GetProductByIDRow
	brands       map[int64]db.Brand
	productUnits map[int64]db.GetProductUnitByIDRow
	barcodes     map[int64]db.ProductBarcode
	nextID       int64
	dupCode      bool
	brandInUse   bool
	dupBarcode   bool
	dupUnit      bool
	clearedBase  []int64
	clearedPrime []int64
	// lastProductFilter captures the ListProducts filter for handler tests.
	lastProductFilter ProductFilter
}

func newFakeStore() *fakeStore {
	return &fakeStore{
		categories: map[int64]db.GetCategoryByIDRow{
			1: {ID: 1, Name: "Sembako", IsActive: true},
		},
		units: map[int64]db.GetUnitByIDRow{
			1: {ID: 1, Name: "Kilogram", Symbol: pgtype.Text{String: "kg", Valid: true}},
			2: {ID: 2, Name: "Box", Symbol: pgtype.Text{String: "box", Valid: true}},
		},
		products: map[int64]db.GetProductByIDRow{},
		brands: map[int64]db.Brand{
			1: {ID: 1, Name: "Keluarga", IsActive: true},
		},
		productUnits: map[int64]db.GetProductUnitByIDRow{},
		barcodes:     map[int64]db.ProductBarcode{},
		nextID:       1,
	}
}

func (f *fakeStore) CategoryByID(_ context.Context, id int64) (db.GetCategoryByIDRow, error) {
	if c, ok := f.categories[id]; ok {
		return c, nil
	}
	return db.GetCategoryByIDRow{}, ErrNotFound
}

func (f *fakeStore) UnitByID(_ context.Context, id int64) (db.GetUnitByIDRow, error) {
	if u, ok := f.units[id]; ok {
		return u, nil
	}
	return db.GetUnitByIDRow{}, ErrNotFound
}

func (f *fakeStore) ListCategories(_ context.Context, filt CatalogFilter) ([]db.ListCategoriesRow, int, error) {
	rows := make([]db.ListCategoriesRow, 0, len(f.categories))
	for _, c := range f.categories {
		if filt.OnlyActive && !c.IsActive {
			continue
		}
		if filt.Search != "" {
			needle := strings.ToLower(filt.Search)
			if !strings.Contains(strings.ToLower(c.Name), needle) && !strings.Contains(strings.ToLower(c.Description), needle) {
				continue
			}
		}
		rows = append(rows, db.ListCategoriesRow{ID: c.ID, Name: c.Name, IsActive: c.IsActive})
	}
	sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
	total := len(rows)
	page, per := filt.Page, filt.PerPage
	if page < 1 {
		page = 1
	}
	if per < 1 || per > 100 {
		per = 20
	}
	start := (page - 1) * per
	if start >= len(rows) {
		return []db.ListCategoriesRow{}, total, nil
	}
	end := start + per
	if end > len(rows) {
		end = len(rows)
	}
	return rows[start:end], total, nil
}
func (f *fakeStore) ListUnits(_ context.Context, filt CatalogFilter) ([]db.ListUnitsRow, int, error) {
	rows := make([]db.ListUnitsRow, 0, len(f.units))
	for _, u := range f.units {
		sym := ""
		if u.Symbol.Valid {
			sym = u.Symbol.String
		}
		if filt.Search != "" {
			needle := strings.ToLower(filt.Search)
			if !strings.Contains(strings.ToLower(u.Name), needle) && !strings.Contains(strings.ToLower(sym), needle) {
				continue
			}
		}
		rows = append(rows, db.ListUnitsRow{ID: u.ID, Name: u.Name, Symbol: u.Symbol})
	}
	sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
	total := len(rows)
	page, per := filt.Page, filt.PerPage
	if page < 1 {
		page = 1
	}
	if per < 1 || per > 100 {
		per = 20
	}
	start := (page - 1) * per
	if start >= len(rows) {
		return []db.ListUnitsRow{}, total, nil
	}
	end := start + per
	if end > len(rows) {
		end = len(rows)
	}
	return rows[start:end], total, nil
}
func (f *fakeStore) CategoryNameExists(context.Context, string, int64) (bool, error) {
	return false, nil
}
func (f *fakeStore) UnitNameExists(context.Context, string, int64) (bool, error) {
	return false, nil
}
func (f *fakeStore) CreateCategory(context.Context, db.CreateCategoryParams) (int64, error) {
	return 9, nil
}
func (f *fakeStore) UpdateCategory(context.Context, db.UpdateCategoryParams) error {
	if _, ok := f.categories[1]; !ok {
		return ErrNotFound
	}
	return nil
}
func (f *fakeStore) DeleteCategory(context.Context, int64) error                    { return nil }
func (f *fakeStore) CreateUnit(context.Context, db.CreateUnitParams) (int64, error) { return 9, nil }
func (f *fakeStore) UpdateUnit(context.Context, db.UpdateUnitParams) error {
	if _, ok := f.units[1]; !ok {
		return ErrNotFound
	}
	return nil
}
func (f *fakeStore) DeleteUnit(context.Context, int64) error { return nil }
func (f *fakeStore) ListProducts(_ context.Context, filt ProductFilter) ([]db.ListProductsRow, int, error) {
	f.lastProductFilter = filt
	return nil, 0, nil
}
func (f *fakeStore) ProductByID(_ context.Context, id int64) (db.GetProductByIDRow, error) {
	if p, ok := f.products[id]; ok {
		return p, nil
	}
	return db.GetProductByIDRow{}, ErrNotFound
}
func (f *fakeStore) CreateProduct(_ context.Context, p db.CreateProductParams) (db.CreateProductRow, error) {
	if f.dupCode {
		return db.CreateProductRow{}, ErrDuplicateCode
	}
	id := f.nextID
	f.nextID++
	now := pgtype.Timestamptz{Time: time.Now(), Valid: true}
	f.products[id] = db.GetProductByIDRow{
		ID:            id,
		Code:          p.Code,
		Name:          p.Name,
		Description:   p.Description,
		CategoryID:    p.CategoryID,
		UnitID:        p.UnitID,
		PurchasePrice: p.PurchasePrice,
		SellingPrice:  p.SellingPrice,
		MinStock:      p.MinStock,
		IsActive:      p.IsActive,
		IsFeatured:    p.IsFeatured,
		Image:         p.Image,
		CreatedAt:     now,
		UpdatedAt:     now,
		ID_2:          1,
		Name_2:        "Sembako",
		Description_2: "",
		ID_3:          1,
		Name_3:        "Kilogram",
		Symbol:        pgtype.Text{String: "kg", Valid: true},
		Description_3: "",
		BrandID:       p.BrandID,
	}
	return db.CreateProductRow{ID: id, CreatedAt: now, UpdatedAt: now}, nil
}
func (f *fakeStore) UpdateProduct(_ context.Context, p db.UpdateProductParams) error {
	if _, ok := f.products[p.ID]; !ok {
		return ErrNotFound
	}
	prev := f.products[p.ID]
	prev.Code = p.Code
	prev.Name = p.Name
	prev.Description = p.Description
	prev.CategoryID = p.CategoryID
	prev.UnitID = p.UnitID
	prev.PurchasePrice = p.PurchasePrice
	prev.SellingPrice = p.SellingPrice
	prev.MinStock = p.MinStock
	prev.IsActive = p.IsActive
	prev.IsFeatured = p.IsFeatured
	prev.Image = p.Image
	prev.BrandID = p.BrandID
	f.products[p.ID] = prev
	return nil
}
func (f *fakeStore) DeleteProduct(context.Context, int64) error { return nil }

func (f *fakeStore) BrandByID(_ context.Context, id int64) (db.Brand, error) {
	if b, ok := f.brands[id]; ok {
		return b, nil
	}
	return db.Brand{}, ErrNotFound
}

func (f *fakeStore) ListBrands(_ context.Context, onlyActive bool) ([]db.Brand, error) {
	out := make([]db.Brand, 0, len(f.brands))
	for _, b := range f.brands {
		if onlyActive && !b.IsActive {
			continue
		}
		out = append(out, b)
	}
	return out, nil
}

func (f *fakeStore) CreateBrand(_ context.Context, p db.CreateBrandParams) (db.Brand, error) {
	id := f.nextID
	f.nextID++
	b := db.Brand{ID: id, Name: p.Name, Description: p.Description, IsActive: p.IsActive}
	f.brands[id] = b
	return b, nil
}

func (f *fakeStore) UpdateBrand(_ context.Context, p db.UpdateBrandParams) error {
	b, ok := f.brands[p.ID]
	if !ok {
		return ErrNotFound
	}
	b.Name = p.Name
	b.Description = p.Description
	b.IsActive = p.IsActive
	f.brands[p.ID] = b
	return nil
}

func (f *fakeStore) DeleteBrand(_ context.Context, id int64) error {
	if f.brandInUse {
		return ErrInUse
	}
	if _, ok := f.brands[id]; !ok {
		return ErrNotFound
	}
	delete(f.brands, id)
	return nil
}

func (f *fakeStore) BrandNameExists(_ context.Context, name string, exceptID int64) (bool, error) {
	for id, b := range f.brands {
		if id != exceptID && strings.EqualFold(b.Name, name) {
			return true, nil
		}
	}
	return false, nil
}

func (f *fakeStore) ListProductUnits(_ context.Context, productID int64, onlyActive bool) ([]db.ListProductUnitsRow, error) {
	out := []db.ListProductUnitsRow{}
	for _, pu := range f.productUnits {
		if pu.ProductID != productID || (onlyActive && !pu.IsActive) {
			continue
		}
		out = append(out, db.ListProductUnitsRow(pu))
	}
	return out, nil
}

func (f *fakeStore) ProductUnitByID(_ context.Context, id int64) (db.GetProductUnitByIDRow, error) {
	if pu, ok := f.productUnits[id]; ok {
		return pu, nil
	}
	return db.GetProductUnitByIDRow{}, ErrNotFound
}

func (f *fakeStore) CreateProductUnit(_ context.Context, p db.CreateProductUnitParams) (int64, error) {
	if f.dupUnit {
		return 0, ErrDuplicateUnitConfig
	}
	for _, pu := range f.productUnits {
		if pu.ProductID == p.ProductID && pu.UnitID == p.UnitID {
			return 0, ErrDuplicateUnitConfig
		}
	}
	id := f.nextID
	f.nextID++
	if p.IsBase {
		f.demoteBase(p.ProductID, id)
	}
	f.productUnits[id] = db.GetProductUnitByIDRow{
		ID:               id,
		ProductID:        p.ProductID,
		UnitID:           p.UnitID,
		ConversionFactor: p.ConversionFactor,
		SellingPrice:     p.SellingPrice,
		MinPurchase:      p.MinPurchase,
		IsBase:           p.IsBase,
		IsActive:         p.IsActive,
		UnitName:         f.units[p.UnitID].Name,
		UnitSymbol:       f.units[p.UnitID].Symbol,
	}
	return id, nil
}

func (f *fakeStore) UpdateProductUnit(_ context.Context, p db.UpdateProductUnitParams) error {
	pu, ok := f.productUnits[p.ID]
	if !ok {
		return ErrNotFound
	}
	for id, other := range f.productUnits {
		if id != p.ID && other.ProductID == pu.ProductID && other.UnitID == p.UnitID {
			return ErrDuplicateUnitConfig
		}
	}
	if p.IsBase {
		f.demoteBase(pu.ProductID, p.ID)
	}
	pu.UnitID = p.UnitID
	pu.ConversionFactor = p.ConversionFactor
	pu.SellingPrice = p.SellingPrice
	pu.MinPurchase = p.MinPurchase
	pu.IsBase = p.IsBase
	pu.IsActive = p.IsActive
	pu.UnitName = f.units[p.UnitID].Name
	pu.UnitSymbol = f.units[p.UnitID].Symbol
	f.productUnits[p.ID] = pu
	return nil
}

func (f *fakeStore) demoteBase(productID, exceptID int64) {
	f.clearedBase = append(f.clearedBase, exceptID)
	for id, pu := range f.productUnits {
		if id != exceptID && pu.ProductID == productID && pu.IsBase {
			pu.IsBase = false
			f.productUnits[id] = pu
		}
	}
}

func (f *fakeStore) DeleteProductUnit(_ context.Context, id int64) error {
	if _, ok := f.productUnits[id]; !ok {
		return ErrNotFound
	}
	delete(f.productUnits, id)
	return nil
}

func (f *fakeStore) ListProductBarcodes(_ context.Context, productID int64) ([]db.ProductBarcode, error) {
	out := []db.ProductBarcode{}
	for _, b := range f.barcodes {
		if b.ProductID == productID {
			out = append(out, b)
		}
	}
	return out, nil
}

func (f *fakeStore) ProductBarcodeByID(_ context.Context, id int64) (db.ProductBarcode, error) {
	if b, ok := f.barcodes[id]; ok {
		return b, nil
	}
	return db.ProductBarcode{}, ErrNotFound
}

func (f *fakeStore) CreateProductBarcode(_ context.Context, p db.CreateProductBarcodeParams) (db.ProductBarcode, error) {
	if f.dupBarcode {
		return db.ProductBarcode{}, ErrDuplicateBarcode
	}
	for _, b := range f.barcodes {
		if b.Barcode == p.Barcode {
			return db.ProductBarcode{}, ErrDuplicateBarcode
		}
	}
	id := f.nextID
	f.nextID++
	if p.IsPrimary {
		f.clearedPrime = append(f.clearedPrime, id)
		for bid, b := range f.barcodes {
			if bid != id && b.ProductID == p.ProductID && b.IsPrimary {
				b.IsPrimary = false
				f.barcodes[bid] = b
			}
		}
	}
	b := db.ProductBarcode{
		ID:            id,
		ProductID:     p.ProductID,
		ProductUnitID: p.ProductUnitID,
		Barcode:       p.Barcode,
		IsPrimary:     p.IsPrimary,
	}
	f.barcodes[id] = b
	return b, nil
}

func (f *fakeStore) DeleteProductBarcode(_ context.Context, id int64) error {
	if _, ok := f.barcodes[id]; !ok {
		return ErrNotFound
	}
	delete(f.barcodes, id)
	return nil
}

func (f *fakeStore) LookupProductByBarcode(_ context.Context, barcode string) (db.LookupProductByBarcodeRow, error) {
	for _, b := range f.barcodes {
		if b.Barcode != barcode {
			continue
		}
		p := f.products[b.ProductID]
		row := db.LookupProductByBarcodeRow{
			BarcodeID:     b.ID,
			Barcode:       b.Barcode,
			IsPrimary:     b.IsPrimary,
			ProductUnitID: b.ProductUnitID,
			ProductID:     p.ID,
			ProductCode:   p.Code,
			ProductName:   p.Name,
			SellingPrice:  p.SellingPrice,
			IsActive:      p.IsActive,
		}
		if b.ProductUnitID.Valid {
			pu := f.productUnits[b.ProductUnitID.Int64]
			row.UnitID = pgtype.Int8{Int64: pu.UnitID, Valid: true}
			row.ConversionFactor = decimal.NullDecimal{Decimal: pu.ConversionFactor, Valid: true}
			row.UnitSellingPrice = pu.SellingPrice
			row.MinPurchase = decimal.NullDecimal{Decimal: pu.MinPurchase, Valid: true}
			row.UnitIsBase = pgtype.Bool{Bool: pu.IsBase, Valid: true}
			row.UnitIsActive = pgtype.Bool{Bool: pu.IsActive, Valid: true}
			row.UnitName = pgtype.Text{String: pu.UnitName, Valid: true}
			row.UnitSymbol = pu.UnitSymbol
		}
		return row, nil
	}
	return db.LookupProductByBarcodeRow{}, ErrNotFound
}

type fakeSeq struct{ n int64 }

func (f *fakeSeq) Sequence(context.Context, string, time.Time, any) (int64, error) {
	f.n++
	return f.n, nil
}

func TestCreateProduct_CodeFormat(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	p, err := svc.CreateProduct(context.Background(), ProductInput{
		Name:          "Beras Premium",
		CategoryID:    1,
		UnitID:        1,
		PurchasePrice: decimal.NewFromInt(10000),
		SellingPrice:  decimal.NewFromInt(12000),
	})
	if err != nil {
		t.Fatal(err)
	}
	want := "SEM-" + time.Now().In(time.UTC).Format("20060102") + "-001"
	if p.Code != want {
		t.Errorf("code = %q, want %q", p.Code, want)
	}
	if p.Category == nil || p.Category.Name != "Sembako" {
		t.Errorf("category not nested in view: %+v", p.Category)
	}
	if p.Unit == nil || p.Unit.Name != "Kilogram" {
		t.Errorf("unit not nested in view: %+v", p.Unit)
	}
}

func TestCreateProduct_PrefixFallback(t *testing.T) {
	if got := categoryPrefix("!!"); got != "PRD" {
		t.Errorf("empty prefix = %q, want PRD", got)
	}
	if got := categoryPrefix("A"); got != "A" {
		t.Errorf("short prefix = %q, want A", got)
	}
}

func TestProduct_IsFeaturedLifecycle(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	ctx := context.Background()

	// Create defaults to false when is_featured is omitted.
	p, err := svc.CreateProduct(ctx, ProductInput{
		Name: "Gula Pasir", CategoryID: 1, UnitID: 1,
		PurchasePrice: decimal.NewFromInt(1000), SellingPrice: decimal.NewFromInt(1500),
	})
	if err != nil {
		t.Fatal(err)
	}
	if p.IsFeatured {
		t.Errorf("created product IsFeatured = true, want false default")
	}

	// Create with is_featured true persists the flag.
	featured := true
	p, err = svc.CreateProduct(ctx, ProductInput{
		Name: "Kopi Signature", CategoryID: 1, UnitID: 1,
		PurchasePrice: decimal.NewFromInt(2000), SellingPrice: decimal.NewFromInt(3000),
		IsFeatured: &featured,
	})
	if err != nil {
		t.Fatal(err)
	}
	if !p.IsFeatured {
		t.Fatalf("created product IsFeatured = false, want true")
	}

	// Update with omitted (nil) is_featured keeps the stored value.
	stored, err := svc.UpdateProduct(ctx, p.ID, ProductInput{
		Name: "Kopi Signature", CategoryID: 1, UnitID: 1,
		PurchasePrice: decimal.NewFromInt(2000), SellingPrice: decimal.NewFromInt(3000),
	})
	if err != nil {
		t.Fatal(err)
	}
	if !stored.IsFeatured {
		t.Errorf("update without is_featured cleared the flag, want preserved true")
	}

	// Update with explicit false clears it.
	clear := false
	cleared, err := svc.UpdateProduct(ctx, p.ID, ProductInput{
		Name: "Kopi Signature", CategoryID: 1, UnitID: 1,
		PurchasePrice: decimal.NewFromInt(2000), SellingPrice: decimal.NewFromInt(3000),
		IsFeatured: &clear,
	})
	if err != nil {
		t.Fatal(err)
	}
	if cleared.IsFeatured {
		t.Errorf("update with is_featured=false did not clear the flag")
	}
}

func TestCreateProduct_CategoryRequired(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	_, err := svc.CreateProduct(context.Background(), ProductInput{Name: "X", UnitID: 1})
	if !errors.Is(err, ErrCategoryRequired) {
		t.Errorf("got %v, want ErrCategoryRequired", err)
	}
}

func TestCreateProduct_UnitRequired(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	_, err := svc.CreateProduct(context.Background(), ProductInput{Name: "X", CategoryID: 1})
	if !errors.Is(err, ErrUnitRequired) {
		t.Errorf("got %v, want ErrUnitRequired", err)
	}
}

func seedProduct(t *testing.T, svc *Service) *ProductView {
	t.Helper()
	p, err := svc.CreateProduct(context.Background(), ProductInput{
		Name: "Air Mineral", CategoryID: 1, UnitID: 1,
		PurchasePrice: decimal.NewFromInt(1000), SellingPrice: decimal.NewFromInt(1500),
	})
	if err != nil {
		t.Fatal(err)
	}
	return p
}

func TestCreateProduct_BrandMustExist(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	missing := int64(404)
	_, err := svc.CreateProduct(context.Background(), ProductInput{
		Name: "X", CategoryID: 1, UnitID: 1, BrandID: &missing,
	})
	if !errors.Is(err, ErrBrandRequired) {
		t.Fatalf("got %v, want ErrBrandRequired", err)
	}

	known := int64(1)
	p, err := svc.CreateProduct(context.Background(), ProductInput{
		Name: "Y", CategoryID: 1, UnitID: 1, BrandID: &known,
	})
	if err != nil {
		t.Fatal(err)
	}
	if p.BrandID == nil || *p.BrandID != known {
		t.Fatalf("brand_id = %v, want %d", p.BrandID, known)
	}
}

func TestDeleteBrand_InUse(t *testing.T) {
	st := newFakeStore()
	st.brandInUse = true
	svc := NewService(st, &fakeSeq{}, time.UTC)
	if err := svc.DeleteBrand(context.Background(), 1); !errors.Is(err, ErrInUse) {
		t.Fatalf("got %v, want ErrInUse", err)
	}
}

func TestCreateBrand_DuplicateName(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	_, err := svc.CreateBrand(context.Background(), BrandInput{Name: "keluarga", IsActive: true})
	if !errors.Is(err, ErrDuplicateName) {
		t.Fatalf("got %v, want ErrDuplicateName", err)
	}
}

func TestCreateProductUnit_ValidationRules(t *testing.T) {
	negative := decimal.NewFromInt(-1)
	tests := []struct {
		name string
		in   ProductUnitInput
		want error
	}{
		{
			name: "unit required",
			in:   ProductUnitInput{ConversionFactor: decimal.NewFromInt(1), MinPurchase: decimal.NewFromInt(1)},
			want: ErrUnitRequired,
		},
		{
			name: "unknown unit",
			in:   ProductUnitInput{UnitID: 99, ConversionFactor: decimal.NewFromInt(1), MinPurchase: decimal.NewFromInt(1)},
			want: ErrUnitRequired,
		},
		{
			name: "zero conversion",
			in:   ProductUnitInput{UnitID: 2, ConversionFactor: decimal.Zero, MinPurchase: decimal.NewFromInt(1)},
			want: ErrInvalidConversion,
		},
		{
			name: "negative conversion",
			in:   ProductUnitInput{UnitID: 2, ConversionFactor: decimal.NewFromInt(-2), MinPurchase: decimal.NewFromInt(1)},
			want: ErrInvalidConversion,
		},
		{
			name: "base unit must be factor one",
			in:   ProductUnitInput{UnitID: 2, ConversionFactor: decimal.NewFromInt(12), MinPurchase: decimal.NewFromInt(1), IsBase: true},
			want: ErrBaseUnitConversion,
		},
		{
			name: "zero min purchase",
			in:   ProductUnitInput{UnitID: 2, ConversionFactor: decimal.NewFromInt(1), MinPurchase: decimal.Zero},
			want: ErrInvalidMinPurchase,
		},
		{
			name: "negative price",
			in:   ProductUnitInput{UnitID: 2, ConversionFactor: decimal.NewFromInt(1), MinPurchase: decimal.NewFromInt(1), SellingPrice: &negative},
			want: ErrInvalidUnitPrice,
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
			p := seedProduct(t, svc)
			_, err := svc.CreateProductUnit(context.Background(), p.ID, tc.in)
			if !errors.Is(err, tc.want) {
				t.Fatalf("got %v, want %v", err, tc.want)
			}
		})
	}
}

func TestCreateProductUnit_StoresConfiguredPriceAndFactor(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	p := seedProduct(t, svc)
	price := decimal.NewFromInt(17000)
	pu, err := svc.CreateProductUnit(context.Background(), p.ID, ProductUnitInput{
		UnitID:           2,
		ConversionFactor: decimal.NewFromInt(12),
		SellingPrice:     &price,
		MinPurchase:      decimal.NewFromInt(2),
		IsActive:         true,
	})
	if err != nil {
		t.Fatal(err)
	}
	if !pu.ConversionFactor.Equal(decimal.NewFromInt(12)) {
		t.Errorf("conversion_factor = %s, want 12", pu.ConversionFactor)
	}
	if pu.SellingPrice == nil || !pu.SellingPrice.Equal(price) {
		t.Errorf("selling_price = %v, want %s", pu.SellingPrice, price)
	}
	if !pu.MinPurchase.Equal(decimal.NewFromInt(2)) {
		t.Errorf("min_purchase = %s, want 2", pu.MinPurchase)
	}
	if pu.UnitName != "Box" {
		t.Errorf("unit_name = %q, want Box", pu.UnitName)
	}
}

func TestCreateProductUnit_BaseUnitIsExclusive(t *testing.T) {
	st := newFakeStore()
	svc := NewService(st, &fakeSeq{}, time.UTC)
	p := seedProduct(t, svc)
	pu, err := svc.CreateProductUnit(context.Background(), p.ID, ProductUnitInput{
		UnitID: 1, ConversionFactor: decimal.NewFromInt(1), MinPurchase: decimal.NewFromInt(1),
		IsBase: true, IsActive: true,
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(st.clearedBase) != 1 || st.clearedBase[0] != pu.ID {
		t.Fatalf("clearedBase = %v, want [%d]", st.clearedBase, pu.ID)
	}
}

func TestProductUnit_RejectsForeignProduct(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	p := seedProduct(t, svc)
	other := seedProduct(t, svc)
	pu, err := svc.CreateProductUnit(context.Background(), p.ID, ProductUnitInput{
		UnitID: 2, ConversionFactor: decimal.NewFromInt(6), MinPurchase: decimal.NewFromInt(1), IsActive: true,
	})
	if err != nil {
		t.Fatal(err)
	}
	_, err = svc.UpdateProductUnit(context.Background(), other.ID, pu.ID, ProductUnitInput{
		UnitID: 2, ConversionFactor: decimal.NewFromInt(6), MinPurchase: decimal.NewFromInt(1), IsActive: true,
	})
	if !errors.Is(err, ErrUnitProductMismatch) {
		t.Fatalf("update got %v, want ErrUnitProductMismatch", err)
	}
	if err := svc.DeleteProductUnit(context.Background(), other.ID, pu.ID); !errors.Is(err, ErrUnitProductMismatch) {
		t.Fatalf("delete got %v, want ErrUnitProductMismatch", err)
	}
}

func TestCreateProductBarcode_RequiresBarcodeAndOwnUnit(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	p := seedProduct(t, svc)
	other := seedProduct(t, svc)

	if _, err := svc.CreateProductBarcode(context.Background(), p.ID, ProductBarcodeInput{Barcode: "   "}); !errors.Is(err, ErrBarcodeRequired) {
		t.Fatalf("got %v, want ErrBarcodeRequired", err)
	}

	pu, err := svc.CreateProductUnit(context.Background(), other.ID, ProductUnitInput{
		UnitID: 2, ConversionFactor: decimal.NewFromInt(12), MinPurchase: decimal.NewFromInt(1), IsActive: true,
	})
	if err != nil {
		t.Fatal(err)
	}
	_, err = svc.CreateProductBarcode(context.Background(), p.ID, ProductBarcodeInput{
		Barcode: "8991234567890", ProductUnitID: &pu.ID,
	})
	if !errors.Is(err, ErrUnitProductMismatch) {
		t.Fatalf("got %v, want ErrUnitProductMismatch", err)
	}
}

func TestCreateProductBarcode_PrimaryIsExclusive(t *testing.T) {
	st := newFakeStore()
	svc := NewService(st, &fakeSeq{}, time.UTC)
	p := seedProduct(t, svc)
	b, err := svc.CreateProductBarcode(context.Background(), p.ID, ProductBarcodeInput{
		Barcode: "8991234567890", IsPrimary: true,
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(st.clearedPrime) != 1 || st.clearedPrime[0] != b.ID {
		t.Fatalf("clearedPrime = %v, want [%d]", st.clearedPrime, b.ID)
	}
}

func TestLookupBarcode_ResolvesConfiguredUnit(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	p := seedProduct(t, svc)
	price := decimal.NewFromInt(17000)
	pu, err := svc.CreateProductUnit(context.Background(), p.ID, ProductUnitInput{
		UnitID: 2, ConversionFactor: decimal.NewFromInt(12), SellingPrice: &price,
		MinPurchase: decimal.NewFromInt(1), IsActive: true,
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := svc.CreateProductBarcode(context.Background(), p.ID, ProductBarcodeInput{
		Barcode: "8991234567890", ProductUnitID: &pu.ID, IsPrimary: true,
	}); err != nil {
		t.Fatal(err)
	}

	got, err := svc.LookupBarcode(context.Background(), " 8991234567890 ")
	if err != nil {
		t.Fatal(err)
	}
	if got.ProductID != p.ID || got.ProductCode != p.Code {
		t.Fatalf("resolved product = %d/%s, want %d/%s", got.ProductID, got.ProductCode, p.ID, p.Code)
	}
	if got.ProductUnit == nil {
		t.Fatal("product_unit missing in lookup")
	}
	if !got.ProductUnit.ConversionFactor.Equal(decimal.NewFromInt(12)) {
		t.Errorf("conversion_factor = %s, want 12", got.ProductUnit.ConversionFactor)
	}
	if got.ProductUnit.SellingPrice == nil || !got.ProductUnit.SellingPrice.Equal(price) {
		t.Errorf("unit selling_price = %v, want %s", got.ProductUnit.SellingPrice, price)
	}
}

// The categories page reported two defects at once: the search box never
// filtered, and the pager never changed the rows. Both came from the same
// cause — ListCategories ignored search and returned every row with no
// total, so the footer counted pages over a body that always rendered the
// whole list. These tests pin the service half: search filters, pagination
// slices with the filtered total, and absent page/per_page normalize.
func TestListCategories_SearchFilters(t *testing.T) {
	st := newFakeStore()
	st.categories[2] = db.GetCategoryByIDRow{ID: 2, Name: "Minuman", IsActive: true}
	st.categories[3] = db.GetCategoryByIDRow{ID: 3, Name: "Makanan", IsActive: true}
	svc := NewService(st, &fakeSeq{}, time.UTC)

	list, total, err := svc.ListCategories(context.Background(), CatalogFilter{Search: "minu"})
	if err != nil {
		t.Fatal(err)
	}
	if total != 1 {
		t.Errorf("total = %d, want 1 (only Minuman matches)", total)
	}
	if len(list) != 1 || list[0].Name != "Minuman" {
		t.Errorf("list = %+v, want exactly [Minuman]", list)
	}
}

func TestListCategories_PaginationSlicesWithFilteredTotal(t *testing.T) {
	st := newFakeStore()
	st.categories[2] = db.GetCategoryByIDRow{ID: 2, Name: "B", IsActive: true}
	st.categories[3] = db.GetCategoryByIDRow{ID: 3, Name: "C", IsActive: true}
	svc := NewService(st, &fakeSeq{}, time.UTC)

	p1, total, err := svc.ListCategories(context.Background(), CatalogFilter{Page: 1, PerPage: 2})
	if err != nil {
		t.Fatal(err)
	}
	p2, _, err := svc.ListCategories(context.Background(), CatalogFilter{Page: 2, PerPage: 2})
	if err != nil {
		t.Fatal(err)
	}
	if total != 3 {
		t.Errorf("total = %d, want 3 (Sembako + B + C)", total)
	}
	if len(p1) != 2 || len(p2) != 1 {
		t.Errorf("page sizes = %d/%d, want 2/1", len(p1), len(p2))
	}
	if p1[0].Name == p2[0].Name {
		t.Errorf("both pages start with %q: the pager would render the same rows twice", p1[0].Name)
	}
}

func TestListCategories_DefaultsNormalize(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	list, total, err := svc.ListCategories(context.Background(), CatalogFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if total != 1 || len(list) != 1 {
		t.Errorf("total/list = %d/%d, want 1/1 (absent page/per_page must not empty the list)", total, len(list))
	}
}

func TestListUnits_SearchAndPagination(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)

	list, total, err := svc.ListUnits(context.Background(), CatalogFilter{Search: "box"})
	if err != nil {
		t.Fatal(err)
	}
	if total != 1 || len(list) != 1 || list[0].Name != "Box" {
		t.Errorf("total/list = %d/%+v, want 1/[Box]", total, list)
	}

	p2, total2, err := svc.ListUnits(context.Background(), CatalogFilter{Page: 2, PerPage: 1})
	if err != nil {
		t.Fatal(err)
	}
	if total2 != 2 || len(p2) != 1 {
		t.Errorf("total/page2 = %d/%d, want 2/1", total2, len(p2))
	}
}

func TestLookupBarcode_UnknownAndEmpty(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	if _, err := svc.LookupBarcode(context.Background(), ""); !errors.Is(err, ErrBarcodeRequired) {
		t.Fatalf("got %v, want ErrBarcodeRequired", err)
	}
	if _, err := svc.LookupBarcode(context.Background(), "nope"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("got %v, want ErrNotFound", err)
	}
}

func TestCreateProduct_MissingCategory(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	_, err := svc.CreateProduct(context.Background(), ProductInput{Name: "X", CategoryID: 99, UnitID: 1})
	if !errors.Is(err, ErrCategoryRequired) {
		t.Errorf("got %v, want ErrCategoryRequired", err)
	}
}

func TestUpdateProduct_RejectsBadUnit(t *testing.T) {
	st := newFakeStore()
	st.categories[2] = db.GetCategoryByIDRow{ID: 2, Name: "Minuman", IsActive: true}
	svc := NewService(st, &fakeSeq{}, time.UTC)
	p, err := svc.CreateProduct(context.Background(), ProductInput{
		Name: "A", CategoryID: 1, UnitID: 1,
		PurchasePrice: decimal.NewFromInt(1000), SellingPrice: decimal.NewFromInt(1500),
	})
	if err != nil {
		t.Fatal(err)
	}
	_, err = svc.UpdateProduct(context.Background(), p.ID, ProductInput{
		Name: "A2", CategoryID: 2, UnitID: 99,
		PurchasePrice: decimal.NewFromInt(1000), SellingPrice: decimal.NewFromInt(1500),
	})
	if !errors.Is(err, ErrUnitRequired) {
		t.Errorf("got %v, want ErrUnitRequired", err)
	}
}
