package cart

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

	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/TaruLab/keluargamart-backend/internal/pricing"
	"github.com/TaruLab/keluargamart-backend/internal/sales"
	"github.com/TaruLab/keluargamart-backend/pkg/httpresponse"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/shopspring/decimal"
)

type fakeCartStore struct {
	carts    map[int64]db.Cart
	items    map[int64][]db.ListCartItemsByCartIDRow
	products map[string]int64
	nextID   int64
}

func newFakeCartStore() *fakeCartStore {
	return &fakeCartStore{
		carts:    map[int64]db.Cart{},
		items:    map[int64][]db.ListCartItemsByCartIDRow{},
		products: map[string]int64{"SKU-1": 1},
		nextID:   1,
	}
}

func ts2t() pgtype.Timestamptz {
	return pgtype.Timestamptz{Time: time.Now(), Valid: true}
}

func (f *fakeCartStore) GetOrCreateCart(_ context.Context, userID int64) (db.Cart, error) {
	if c, ok := f.carts[userID]; ok {
		return c, nil
	}
	c := db.Cart{ID: f.nextID, UserID: userID, CreatedAt: ts2t(), UpdatedAt: ts2t()}
	f.nextID++
	f.carts[userID] = c
	return c, nil
}

func (f *fakeCartStore) GetCartByUserID(_ context.Context, userID int64) (db.Cart, error) {
	c, ok := f.carts[userID]
	if !ok {
		return db.Cart{}, ErrNotFound
	}
	return c, nil
}

func (f *fakeCartStore) LockCartTx(ctx context.Context, _ db.DBTX, userID int64) (db.Cart, error) {
	return f.GetCartByUserID(ctx, userID)
}
func (f *fakeCartStore) CheckoutReceiptTx(context.Context, db.DBTX, int64, string, string) (*PlacedOrder, error) {
	return nil, nil
}
func (f *fakeCartStore) SaveCheckoutReceiptTx(context.Context, db.DBTX, int64, string, string, *PlacedOrder) error {
	return nil
}

func (f *fakeCartStore) ListCartItems(_ context.Context, cartID int64) ([]db.ListCartItemsByCartIDRow, error) {
	out := make([]db.ListCartItemsByCartIDRow, 0, len(f.items[cartID]))
	for _, it := range f.items[cartID] {
		out = append(out, it)
	}
	return out, nil
}

func (f *fakeCartStore) AddOrUpdateItem(_ context.Context, cartID, productID int64, qty decimal.Decimal) (db.CartItem, error) {
	for i := range f.items[cartID] {
		if f.items[cartID][i].ProductID == productID {
			f.items[cartID][i].Quantity = f.items[cartID][i].Quantity.Add(qty)
			return db.CartItem{
				ID: f.items[cartID][i].ID, CartID: cartID, ProductID: productID,
				Quantity: f.items[cartID][i].Quantity, CreatedAt: ts2t(), UpdatedAt: ts2t(),
			}, nil
		}
	}
	it := db.ListCartItemsByCartIDRow{
		ID: f.nextID, CartID: cartID, ProductID: productID, Quantity: qty,
		ID_2: productID, Code: "SKU-1", Name: "Product 1",
		SellingPrice: decimal.NewFromInt(100), Image: pgtype.Text{},
		CreatedAt: ts2t(), UpdatedAt: ts2t(),
	}
	f.nextID++
	f.items[cartID] = append(f.items[cartID], it)
	return db.CartItem{
		ID: it.ID, CartID: cartID, ProductID: productID, Quantity: qty,
		CreatedAt: ts2t(), UpdatedAt: ts2t(),
	}, nil
}

func (f *fakeCartStore) UpdateItemQuantity(_ context.Context, cartID, productID int64, qty decimal.Decimal) error {
	for i := range f.items[cartID] {
		if f.items[cartID][i].ProductID == productID {
			f.items[cartID][i].Quantity = qty
			return nil
		}
	}
	return ErrItemNotFound
}

func (f *fakeCartStore) RemoveItem(_ context.Context, cartID, productID int64) error {
	out := []db.ListCartItemsByCartIDRow{}
	for _, it := range f.items[cartID] {
		if it.ProductID != productID {
			out = append(out, it)
		}
	}
	f.items[cartID] = out
	return nil
}

func (f *fakeCartStore) ClearCart(_ context.Context, cartID int64) error {
	f.items[cartID] = []db.ListCartItemsByCartIDRow{}
	return nil
}

func (f *fakeCartStore) Begin(_ context.Context) (Tx, error) { return fakeCartTx{f: f}, nil }

func (f *fakeCartStore) ListCartItemsTx(_ context.Context, _ db.DBTX, cartID int64) ([]db.ListCartItemsByCartIDRow, error) {
	return f.ListCartItems(context.Background(), cartID)
}

func (f *fakeCartStore) ClearCartTx(_ context.Context, _ db.DBTX, cartID int64) error {
	return f.ClearCart(context.Background(), cartID)
}

type fakeCartTx struct{ f *fakeCartStore }

func (fakeCartTx) QueryRow(context.Context, string, ...any) pgx.Row { return nil }
func (fakeCartTx) Query(context.Context, string, ...any) (pgx.Rows, error) {
	return nil, nil
}
func (fakeCartTx) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
	return pgconn.CommandTag{}, nil
}
func (t fakeCartTx) Commit(context.Context) error   { return nil }
func (t fakeCartTx) Rollback(context.Context) error { return nil }

func (f *fakeCartStore) GetProductBySKU(_ context.Context, sku string) (int64, error) {
	if id, ok := f.products[sku]; ok {
		return id, nil
	}
	return 0, ErrProductNotFound
}

func TestGetCart_ReturnsEmptyWhenMissing(t *testing.T) {
	s := NewService(newFakeCartStore())
	c, err := s.GetCart(context.Background(), 42)
	if err != nil {
		t.Fatal(err)
	}
	if c.UserID != 42 {
		t.Errorf("user_id = %d, want 42", c.UserID)
	}
	if len(c.Items) != 0 {
		t.Errorf("items = %d, want 0", len(c.Items))
	}
}

func TestAddItem_BySKU(t *testing.T) {
	s := NewService(newFakeCartStore())
	c, err := s.AddItem(context.Background(), 42, "SKU-1", decimal.NewFromInt(3))
	if err != nil {
		t.Fatal(err)
	}
	if len(c.Items) != 1 || c.Items[0].ProductID != 1 || c.Items[0].Quantity.String() != "3" {
		t.Errorf("items = %+v, want 1 x SKU-1 x 3", c.Items)
	}
}

func TestAddItem_RejectsNonPositive(t *testing.T) {
	s := NewService(newFakeCartStore())
	_, err := s.AddItem(context.Background(), 42, "SKU-1", decimal.Zero)
	if !errors.Is(err, ErrInvalidQuantity) {
		t.Errorf("got %v, want ErrInvalidQuantity", err)
	}
}

func TestCheckout_PlacesAndClears(t *testing.T) {
	fs := newFakeCartStore()
	s := NewService(fs)
	if _, err := s.AddItem(context.Background(), 42, "SKU-1", decimal.NewFromInt(2)); err != nil {
		t.Fatal(err)
	}
	var gotItems []PlacedItem
	var gotIn CheckoutInput
	s.WithOrderPlacer(func(_ context.Context, _ db.DBTX, userID int64, name, email string, in CheckoutInput, items []PlacedItem) (*PlacedOrder, error) {
		gotItems = items
		gotIn = in
		return &PlacedOrder{
			ID: 1, SoNumber: "SO-20260816-0001", CustomerID: 5,
			Status: "confirmed", OrderType: "ecommerce",
			Subtotal: decimal.NewFromInt(200), TotalAmount: decimal.NewFromInt(200),
			RemainingAmount: decimal.NewFromInt(200),
		}, nil
	})
	ship := "Jl. Test"
	po, err := s.Checkout(context.Background(), 42, "Buyer", "buyer@keluargamart.local",
		CheckoutInput{PaymentMethod: "qris", ShippingAddress: &ship})
	if err != nil {
		t.Fatal(err)
	}
	if po.SoNumber != "SO-20260816-0001" {
		t.Errorf("so_number = %s", po.SoNumber)
	}
	if len(gotItems) != 1 || gotItems[0].ProductID != 1 || gotItems[0].Quantity.String() != "2" {
		t.Errorf("placed items = %+v", gotItems)
	}
	if gotIn.PaymentMethod != "qris" || *gotIn.ShippingAddress != "Jl. Test" {
		t.Errorf("input = %+v", gotIn)
	}
	if len(fs.items[fs.carts[42].ID]) != 0 {
		t.Error("cart not cleared after checkout")
	}
}

func TestCheckout_EmptyCart(t *testing.T) {
	s := NewService(newFakeCartStore())
	s.WithOrderPlacer(func(_ context.Context, _ db.DBTX, _ int64, _, _ string, _ CheckoutInput, _ []PlacedItem) (*PlacedOrder, error) {
		return &PlacedOrder{}, nil
	})
	_, err := s.Checkout(context.Background(), 42, "Buyer", "buyer@keluargamart.local", CheckoutInput{})
	if !errors.Is(err, ErrEmptyCart) {
		t.Errorf("got %v, want ErrEmptyCart", err)
	}
}

func TestCheckout_NotConfigured(t *testing.T) {
	s := NewService(newFakeCartStore())
	if _, err := s.AddItem(context.Background(), 42, "SKU-1", decimal.NewFromInt(1)); err != nil {
		t.Fatal(err)
	}
	_, err := s.Checkout(context.Background(), 42, "Buyer", "buyer@keluargamart.local", CheckoutInput{})
	if !errors.Is(err, ErrNotConfigured) {
		t.Errorf("got %v, want ErrNotConfigured", err)
	}
}

func TestCheckout_InsufficientStock(t *testing.T) {
	s := NewService(newFakeCartStore())
	if _, err := s.AddItem(context.Background(), 42, "SKU-1", decimal.NewFromInt(1)); err != nil {
		t.Fatal(err)
	}
	s.WithOrderPlacer(func(_ context.Context, _ db.DBTX, _ int64, _, _ string, _ CheckoutInput, _ []PlacedItem) (*PlacedOrder, error) {
		return nil, sales.ErrInsufficientStock
	})
	_, err := s.Checkout(context.Background(), 42, "Buyer", "buyer@keluargamart.local", CheckoutInput{})
	if !errors.Is(err, sales.ErrInsufficientStock) {
		t.Errorf("got %v, want sales.ErrInsufficientStock", err)
	}
}

func TestRespondErr_InsufficientStock(t *testing.T) {
	req := httptest.NewRequest(http.MethodPost, "/api/v1/cart/checkout", nil)
	rec := httptest.NewRecorder()

	respondErr(rec, req, sales.ErrInsufficientStock)

	if rec.Code != http.StatusConflict {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
	}

	var body struct {
		Error struct {
			Code    string `json:"code"`
			Message string `json:"message"`
		} `json:"error"`
	}
	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
		t.Fatalf("failed to decode response body: %v", err)
	}
	if body.Error.Code != string(httpresponse.CodeBusinessViolation) {
		t.Errorf("code = %q, want %q", body.Error.Code, httpresponse.CodeBusinessViolation)
	}
}

func TestCheckoutPricingFailuresAreNotServerErrors(t *testing.T) {
	for _, failure := range []error{pricing.ErrBelowMinPurchase, pricing.ErrProductInactive, pricing.ErrUnitInactive, sales.ErrCheckoutValidation} {
		rec := httptest.NewRecorder()
		respondErr(rec, httptest.NewRequest(http.MethodPost, "/api/v1/cart/checkout", nil), failure)
		if rec.Code != http.StatusBadRequest {
			t.Errorf("%v mapped to %d, want 400", failure, rec.Code)
		}
	}
}

// No sentinel the sales placer can return may fall through respondErr to the
// generic 500. The B2B checkout surfaces that 500 as "Something went wrong",
// which tells a buyer nothing actionable. Every time a new sentinel is added
// to internal/sales, this table must gain a row — if it does not, the next
// missing mapping is invisible until a real buyer hits it.
func TestRespondErr_NeverMasksASalesSentinelAs500(t *testing.T) {
	sentinelErrs := map[string]error{
		"ErrNotFound":               sales.ErrNotFound,
		"ErrInvalidStatus":          sales.ErrInvalidStatus,
		"ErrItemRequired":           sales.ErrItemRequired,
		"ErrCannotModify":           sales.ErrCannotModify,
		"ErrCannotCancel":           sales.ErrCannotCancel,
		"ErrPaidCannotCancel":       sales.ErrPaidCannotCancel,
		"ErrCancelled":              sales.ErrCancelled,
		"ErrCustomerRequired":       sales.ErrCustomerRequired,
		"ErrInsufficientStock":      sales.ErrInsufficientStock,
		"ErrCheckoutValidation":     sales.ErrCheckoutValidation,
		"ErrVoucherNotUsable":       sales.ErrVoucherNotUsable,
		"ErrFulfillmentForbidden":   sales.ErrFulfillmentForbidden,
		"ErrUnpaidOrderFulfillment": sales.ErrUnpaidOrderFulfillment,
		"ErrPaymentNotAllowed":      sales.ErrPaymentNotAllowed,
		"ErrGatewayDisabled":        sales.ErrGatewayDisabled,
	}
	for name, sentinel := range sentinelErrs {
		rec := httptest.NewRecorder()
		respondErr(rec, httptest.NewRequest(http.MethodPost, "/api/v1/cart/checkout", nil), sentinel)
		if rec.Code == http.StatusInternalServerError {
			t.Errorf("sales.%s fell through to %d (Something went wrong); add a respondErr mapping that mirrors internal/sales", name, http.StatusInternalServerError)
		}
		if rec.Code < 400 || rec.Code > 503 {
			t.Errorf("sales.%s mapped to %d, want a 4xx or 503", name, rec.Code)
		}
	}
}

// ErrGatewayDisabled is masked: the message must never name the configured
// gateway or its keys, because the raw sentinel text is checked by the caller
// (internal/sales keeps it deliberately generic too).
func TestRespondErr_GatewayDisabledDoesNotLeak(t *testing.T) {
	rec := httptest.NewRecorder()
	respondErr(rec, httptest.NewRequest(http.MethodPost, "/api/v1/cart/checkout", nil), sales.ErrGatewayDisabled)
	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("status = %d, want 503", rec.Code)
	}
	if strings.Contains(rec.Body.String(), sales.ErrGatewayDisabled.Error()) {
		t.Errorf("response leaks the raw sentinel text: %s", rec.Body.String())
	}
}
