package integration

import (
	"context"
	"errors"
	"fmt"
	"os"
	"sync/atomic"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/redis/go-redis/v9"
	"github.com/shopspring/decimal"

	"github.com/TaruLab/keluargamart-backend/internal/cart"
	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/TaruLab/keluargamart-backend/internal/inventory"
	"github.com/TaruLab/keluargamart-backend/internal/membership"
	"github.com/TaruLab/keluargamart-backend/internal/pricing"
	"github.com/TaruLab/keluargamart-backend/internal/sales"
	redislib "github.com/TaruLab/keluargamart-backend/pkg/redis"
	"github.com/TaruLab/keluargamart-backend/pkg/seq"
)

var wib = time.FixedZone("WIB", 7*3600)

type seqAdapter struct{ pool *pgxpool.Pool }

// Sequence allocates on the supplied transaction querier when present,
// matching the production kvSeq adapter (§17: allocation inside the caller's
// transaction, pool only as fallback for master codes).
func (a seqAdapter) Sequence(ctx context.Context, prefix string, date time.Time, tx any) (int64, error) {
	q := db.DBTX(a.pool)
	if t, ok := tx.(db.DBTX); ok && t != nil {
		q = t
	}
	return seq.Next(ctx, q, prefix, date)
}

// requireDestructiveConsent gates this package, which TRUNCATEs roughly forty
// tables — users, customers, products, sales_orders among them — via
// resetTables below.
//
// It used to run against whatever DATABASE_URL pointed at, and "DATABASE_URL
// is unset" was the only guard. That is not theoretical: pointing the
// variable at the deployed Postgres to run the rest of the suite emptied it —
// 58 users, 215 sales orders, 34 customers, 47 products — and the only reason
// it was noticed is that a login started failing.
//
// A row-count check is not the fix, because this package is also run against
// a database that migrations have already populated: CI applies the migration
// directory to its Postgres service and THEN runs `go test ./...`, so a
// populated database is the normal case there and cannot be the trigger.
//
// So consent is explicit instead. Two variables, one of which is named for
// what it permits, so emptying a database takes a deliberate act rather than
// an inherited environment variable. CI sets it against its own throwaway
// container; a developer running these by hand has to type it.
const destructiveConsentEnv = "KG_ALLOW_DESTRUCTIVE_DB_TESTS"

func requireDestructiveConsent(t *testing.T) {
	t.Helper()
	if os.Getenv(destructiveConsentEnv) != "1" {
		t.Skipf("%s is not set to 1 — these tests truncate users, customers, "+
			"products and sales_orders. Point DATABASE_URL at a throwaway database "+
			"and set %s=1 to run them.", destructiveConsentEnv, destructiveConsentEnv)
	}
}

func testPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	databaseURL := os.Getenv("DATABASE_URL")
	if databaseURL == "" {
		t.Skip("DATABASE_URL is not set")
	}
	requireDestructiveConsent(t)
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, databaseURL)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(pool.Close)
	return pool
}

// testPoolWithMaxConns builds a deliberately small pool. Any use case that takes
// a second connection while already holding a transaction deadlocks here instead
// of only under production load.
func testPoolWithMaxConns(t *testing.T, maxConns int32) *pgxpool.Pool {
	t.Helper()
	databaseURL := os.Getenv("DATABASE_URL")
	if databaseURL == "" {
		t.Skip("DATABASE_URL is not set")
	}
	requireDestructiveConsent(t)
	cfg, err := pgxpool.ParseConfig(databaseURL)
	if err != nil {
		t.Fatal(err)
	}
	cfg.MaxConns = maxConns
	pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(pool.Close)
	requireDestructiveConsent(t)
	return pool
}

func testRedis(t *testing.T) *redis.Client {
	t.Helper()
	addr := os.Getenv("REDIS_ADDR")
	if addr == "" {
		t.Skip("REDIS_ADDR is not set")
	}
	rdb := redislib.New(redisURL(addr))
	if err := redislib.Ping(context.Background(), rdb); err != nil {
		t.Fatal(err)
	}
	return rdb
}

// redisURL normalizes a bare host:port into a full redis:// URL.
func redisURL(addr string) string {
	if addr[:7] != "redis://" {
		return "redis://" + addr
	}
	return addr
}

func setUp(t *testing.T, pool *pgxpool.Pool) {
	t.Helper()
	ctx := context.Background()
	tables := []string{
		"cart_items", "carts",
		"stock_movements", "stocks",
		"sales_order_status_history", "sales_order_items", "sales_orders",
		"payments", "invoices", "cashflows", "sales_vouchers", "galon_transactions",
		"sales_return_items", "sales_returns",
		"cashier_drawer_entries", "cashier_shifts",
		"delivery_status_history", "dispatches", "shipment_items", "shipments",
		"shipment_tracking_events",
		"delivery_order_items", "delivery_orders", "fleet",
		"product_barcodes", "product_units",
		"membership_tier_history", "memberships",
		"customers", "products", "brands", "warehouses", "categories", "units", "users",
		// LHPP group tables first, children before parents: the 000101 backfill
		// leaves real lhpp_group_items rows pointing at delivery_orders, so
		// every other test's TRUNCATE would otherwise fail on the FK from those
		// rows. lhpp_group_items/lhpp_group_history first for the same reason.
		"lhpp_group_history", "lhpp_group_items", "lhpp_groups",
		"document_number_sequences", "accounting_periods",
	}
	for _, tbl := range tables {
		if _, err := pool.Exec(ctx, "TRUNCATE TABLE "+tbl+" RESTART IDENTITY CASCADE"); err != nil {
			t.Fatalf("truncate %s: %v", tbl, err)
		}
	}
}

var uniqueCounter int64

func unique(prefix string) string {
	n := atomic.AddInt64(&uniqueCounter, 1)
	return fmt.Sprintf("%s-%04d", prefix, n)
}

func decimalPtr(s string) *decimal.Decimal {
	d := decimal.RequireFromString(s)
	return &d
}

func soCount(t *testing.T, pool *pgxpool.Pool) int {
	t.Helper()
	var n int
	if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM sales_orders`).Scan(&n); err != nil {
		t.Fatal(err)
	}
	return n
}

func seedWarehouse(t *testing.T, pool *pgxpool.Pool) int64 {
	t.Helper()
	var id int64
	if err := pool.QueryRow(context.Background(),
		`INSERT INTO warehouses (code, name, is_active) VALUES ($1, $2, true) RETURNING id`,
		unique("WH"), "Integration WH").Scan(&id); err != nil {
		t.Fatal(err)
	}
	return id
}

func seedCategory(t *testing.T, pool *pgxpool.Pool) int64 {
	t.Helper()
	var id int64
	if err := pool.QueryRow(context.Background(),
		`INSERT INTO categories (name) VALUES ($1) RETURNING id`, unique("CAT")).Scan(&id); err != nil {
		t.Fatal(err)
	}
	return id
}

func seedUnit(t *testing.T, pool *pgxpool.Pool) int64 {
	t.Helper()
	var id int64
	if err := pool.QueryRow(context.Background(),
		`INSERT INTO units (name, symbol) VALUES ($1, $2) RETURNING id`, unique("UNIT"), "pcs").Scan(&id); err != nil {
		t.Fatal(err)
	}
	return id
}

type seededProduct struct {
	ID   int64
	Code string
}

func seedProduct(t *testing.T, pool *pgxpool.Pool, categoryID, unitID int64, sellingPrice string) seededProduct {
	t.Helper()
	var p seededProduct
	p.Code = unique("PRD")
	if err := pool.QueryRow(context.Background(),
		`INSERT INTO products (code, name, category_id, unit_id, selling_price, is_active)
		 VALUES ($1, $2, $3, $4, $5, true) RETURNING id`,
		p.Code, "Integration Product", categoryID, unitID, sellingPrice).Scan(&p.ID); err != nil {
		t.Fatal(err)
	}
	return p
}

func seedStock(t *testing.T, pool *pgxpool.Pool, productID, warehouseID int64, quantity string) {
	t.Helper()
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO stocks (product_id, warehouse_id, quantity, last_updated_at)
		 VALUES ($1, $2, $3, now())
		 ON CONFLICT (product_id, warehouse_id) DO UPDATE SET quantity = excluded.quantity, last_updated_at = now()`,
		productID, warehouseID, quantity); err != nil {
		t.Fatal(err)
	}
}

func seedCustomer(t *testing.T, pool *pgxpool.Pool) int64 {
	t.Helper()
	var id int64
	if err := pool.QueryRow(context.Background(),
		`INSERT INTO customers (code, name, is_active) VALUES ($1, $2, true) RETURNING id`,
		unique("CUS"), "Integration Customer").Scan(&id); err != nil {
		t.Fatal(err)
	}
	return id
}

func seedUser(t *testing.T, pool *pgxpool.Pool) int64 {
	t.Helper()
	var id int64
	if err := pool.QueryRow(context.Background(),
		`INSERT INTO users (name, email, password, role, is_active)
		 VALUES ($1, $2, $3, 'admin', true) RETURNING id`,
		"Integration User", unique("EMAIL")+"@test.local", "x").Scan(&id); err != nil {
		t.Fatal(err)
	}
	return id
}

func stockQty(t *testing.T, pool *pgxpool.Pool, productID, warehouseID int64) decimal.Decimal {
	t.Helper()
	var q decimal.Decimal
	if err := pool.QueryRow(context.Background(),
		`SELECT quantity FROM stocks WHERE product_id = $1 AND warehouse_id = $2`,
		productID, warehouseID).Scan(&q); err != nil {
		t.Fatal(err)
	}
	return q
}

func movementCount(t *testing.T, pool *pgxpool.Pool) int {
	t.Helper()
	var n int
	if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM stock_movements`).Scan(&n); err != nil {
		t.Fatal(err)
	}
	return n
}

func salesService(pool *pgxpool.Pool, inv *inventory.Service) *sales.Service {
	return sales.NewService(sales.NewRepository(pool), seqAdapter{pool}, wib).
		WithAllowNegativeStock(false).
		WithPricing(pricingResolver(pool)).
		WithPOSLedger(func(ctx context.Context, q db.DBTX, productID, warehouseID, orderID int64, before, after, qty decimal.Decimal) error {
			n, err := seq.Next(ctx, q, "sm", time.Now().In(wib))
			if err != nil {
				return err
			}
			rt := "sales_order"
			m := &inventory.Movement{
				MovementNumber: fmt.Sprintf("SM-%s-%04d", time.Now().In(wib).Format("20060102"), n),
				ProductID:      productID,
				WarehouseID:    warehouseID,
				MovementType:   inventory.MovementStockOut,
				Quantity:       qty.Round(4),
				QuantityBefore: before.Round(4),
				QuantityAfter:  after.Round(4),
				ReferenceType:  &rt,
				ReferenceID:    &orderID,
			}
			return inv.MovementInTx(ctx, q, m)
		})
}

// pricingResolver mirrors the production wiring in cmd/server/main.go so the
// integration suite exercises the same authoritative pricing path, including
// resolving on the caller's transaction rather than a second pool connection,
// and the Phase 6 item 3 membership benefit stage (§13.1).
func pricingResolver(pool *pgxpool.Pool) sales.PriceResolver {
	membershipRepo := membership.NewRepository(pool)
	svc := pricing.NewService(pricing.NewRepository(pool))
	svc.WithMembershipLookup(func(ctx context.Context, q db.DBTX, customerID int64) (string, bool, error) {
		row, err := membershipRepo.MembershipByCustomer(ctx, q, customerID)
		if err != nil {
			if errors.Is(err, membership.ErrNotFound) {
				return "", false, nil
			}
			return "", false, err
		}
		if !row.IsActive {
			return "", false, nil
		}
		return row.Tier, true, nil
	})
	return func(ctx context.Context, q db.DBTX, reqs []sales.PriceRequest) ([]sales.PricedLine, error) {
		in := make([]pricing.Request, 0, len(reqs))
		for _, r := range reqs {
			in = append(in, pricing.Request{
				ProductID:     r.ProductID,
				ProductUnitID: r.ProductUnitID,
				Quantity:      r.Quantity,
				CustomerID:    r.CustomerID,
			})
		}
		resolved, err := svc.ResolveAll(ctx, q, in)
		if err != nil {
			return nil, err
		}
		out := make([]sales.PricedLine, 0, len(resolved))
		for _, r := range resolved {
			out = append(out, sales.PricedLine{
				ProductID:                r.ProductID,
				ProductUnitID:            r.ProductUnitID,
				UnitID:                   r.UnitID,
				UnitName:                 r.UnitName,
				UnitSymbol:               r.UnitSymbol,
				ConversionFactor:         r.ConversionFactor,
				UnitPrice:                r.UnitPrice,
				MinPurchase:              r.MinPurchase,
				Quantity:                 r.Quantity,
				BaseQuantity:             r.BaseQuantity,
				MembershipTier:           r.MembershipTier,
				MembershipDiscountAmount: r.MembershipDiscountAmount,
			})
		}
		return out, nil
	}
}

func cartService(pool *pgxpool.Pool, salesSvc *sales.Service) *cart.Service {
	return cart.NewService(cart.NewRepository(pool)).WithOrderPlacer(func(ctx context.Context, q db.DBTX, userID int64, userName, userEmail string, in cart.CheckoutInput, items []cart.PlacedItem) (*cart.PlacedOrder, error) {
		so, err := salesSvc.PlaceCheckoutInTx(ctx, q, sales.CheckoutInput{
			WarehouseID:     in.WarehouseID,
			PaymentMethod:   in.PaymentMethod,
			ShippingAddress: in.ShippingAddress,
			Notes:           in.Notes,
			Items:           mapPlacedItems(items),
		}, userID, userName, userEmail)
		if err != nil {
			return nil, err
		}
		po := &cart.PlacedOrder{
			ID: so.ID, SoNumber: so.SoNumber, CustomerID: so.CustomerID,
			Status: so.Status, OrderType: so.OrderType,
			Subtotal: so.Subtotal, TotalAmount: so.TotalAmount,
			PaidAmount: so.PaidAmount, RemainingAmount: so.RemainingAmount,
		}
		for _, it := range so.Items {
			po.Items = append(po.Items, cart.PlacedOrderItem{
				ID: it.ID, ProductID: it.ProductID, Quantity: it.Quantity,
				UnitPrice: it.UnitPrice, Subtotal: it.Subtotal,
			})
		}
		return po, nil
	})
}

func mapPlacedItems(items []cart.PlacedItem) []sales.CheckoutItemInput {
	out := make([]sales.CheckoutItemInput, 0, len(items))
	for _, it := range items {
		out = append(out, sales.CheckoutItemInput{ProductID: it.ProductID, Quantity: it.Quantity})
	}
	return out
}
