package integration

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/shopspring/decimal"

	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/TaruLab/keluargamart-backend/internal/distribution"
	"github.com/TaruLab/keluargamart-backend/internal/inventory"
	"github.com/TaruLab/keluargamart-backend/internal/sales"
)

func distributionService(pool *pgxpool.Pool, inv *inventory.Service) *distribution.Service {
	return distribution.NewService(distribution.NewRepository(pool), seqAdapter{pool}, wib).
		WithShipmentLedger(func(ctx context.Context, q db.DBTX, productID, warehouseID, shipmentID int64, before, after, qty decimal.Decimal) error {
			n, err := seqAdapter{pool}.Sequence(ctx, "sm", time.Now().In(wib), q)
			if err != nil {
				return err
			}
			rt := "shipment"
			m := &inventory.Movement{
				MovementNumber: "SM-" + time.Now().In(wib).Format("20060102") + "-" + pad(n),
				ProductID:      productID,
				WarehouseID:    warehouseID,
				MovementType:   inventory.MovementStockOut,
				Quantity:       qty.Round(4),
				QuantityBefore: before.Round(4),
				QuantityAfter:  after.Round(4),
				ReferenceType:  &rt,
				ReferenceID:    &shipmentID,
			}
			return inv.MovementInTx(ctx, q, m)
		})
}

// TestDOPartialFulfillmentGate is the Phase 4 gate: 770 -> 500 + 270 under one
// DO. Over-shipment aborts atomically, stock/delivery totals match the DO, and
// dispatch ordering + reassignment are audited.
func TestDOPartialFulfillmentGate(t *testing.T) {
	pool := testPool(t)
	setUp(t, pool)
	ctx := context.Background()

	cust := seedCustomer(t, pool)
	wh := seedWarehouse(t, pool)
	cat := seedCategory(t, pool)
	unit := seedUnit(t, pool)
	p := seedProduct(t, pool, cat, unit, "100")
	seedStock(t, pool, p.ID, wh, "770")
	op := seedUser(t, pool)

	invSvc := inventory.NewService(inventory.NewRepository(pool), seqAdapter{pool}, wib)
	salesSvc := salesService(pool, invSvc)
	distSvc := distributionService(pool, invSvc)

	// regular SO reserves 770; confirm unlocks DO creation
	so, err := salesSvc.Create(ctx, sales.SOInput{
		CustomerID:  cust,
		WarehouseID: &wh,
		OrderDate:   time.Now().In(wib).Format("2006-01-02"),
		Items:       []sales.SOItemInput{{ProductID: p.ID, Quantity: decimal.NewFromInt(770), UnitPrice: decimal.NewFromInt(100)}},
	})
	if err != nil {
		t.Fatalf("SO create: %v", err)
	}
	if _, err := salesSvc.Confirm(ctx, so.ID); err != nil {
		t.Fatalf("SO confirm: %v", err)
	}

	do, err := distSvc.CreateDeliveryOrder(ctx, distribution.DOInput{
		SalesOrderID: so.ID,
		Items:        []distribution.DOItemInput{{ProductID: p.ID, RequestedQuantity: decimal.NewFromInt(770)}},
	}, op)
	if err != nil {
		t.Fatalf("CreateDeliveryOrder: %v", err)
	}
	if do.Status != distribution.StatusDraft || do.RemainingQuantity.String() != "770" {
		t.Fatalf("DO = status %s remaining %s", do.Status, do.RemainingQuantity)
	}

	// shipment 1: 500 of 770
	sh1, err := distSvc.CreateShipment(ctx, do.ID, distribution.ShipmentInput{
		Items: []distribution.ShipmentItemInput{{ProductID: p.ID, ShippedQuantity: decimal.NewFromInt(500)}},
	}, op)
	if err != nil {
		t.Fatalf("CreateShipment(500): %v", err)
	}
	if q := stockQty(t, pool, p.ID, wh); q.String() != "270" {
		t.Fatalf("stock after 500 = %s, want 270", q)
	}
	do, err = distSvc.GetDeliveryOrder(ctx, do.ID)
	if err != nil {
		t.Fatal(err)
	}
	if do.Status != distribution.StatusPartiallyFulfilled || do.RemainingQuantity.String() != "270" {
		t.Fatalf("DO after 500 = %s remaining %s", do.Status, do.RemainingQuantity)
	}

	// over-shipment must abort without partial writes
	if _, err := distSvc.CreateShipment(ctx, do.ID, distribution.ShipmentInput{
		Items: []distribution.ShipmentItemInput{{ProductID: p.ID, ShippedQuantity: decimal.NewFromInt(400)}},
	}, op); !errors.Is(err, distribution.ErrOverShipment) {
		t.Fatalf("over-ship err = %v, want ErrOverShipment", err)
	}
	var shipCount int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM shipments WHERE delivery_order_id=$1`, do.ID).Scan(&shipCount); err != nil {
		t.Fatal(err)
	}
	if shipCount != 1 {
		t.Fatalf("shipments after failed over-ship = %d, want 1", shipCount)
	}
	if q := stockQty(t, pool, p.ID, wh); q.String() != "270" {
		t.Fatalf("stock after failed over-ship = %s, want 270", q)
	}

	// shipment 2: remaining 270 closes the DO (ready, awaiting delivery)
	sh2, err := distSvc.CreateShipment(ctx, do.ID, distribution.ShipmentInput{
		Items: []distribution.ShipmentItemInput{{ProductID: p.ID, ShippedQuantity: decimal.NewFromInt(270)}},
	}, op)
	if err != nil {
		t.Fatalf("CreateShipment(270): %v", err)
	}
	do, err = distSvc.GetDeliveryOrder(ctx, do.ID)
	if err != nil {
		t.Fatal(err)
	}
	if do.Status != distribution.StatusReady || do.ShippedQuantity.String() != "770" || do.RemainingQuantity.String() != "0" {
		t.Fatalf("DO after 270 = %s shipped %s remaining %s", do.Status, do.ShippedQuantity, do.RemainingQuantity)
	}
	if q := stockQty(t, pool, p.ID, wh); q.String() != "0" {
		t.Fatalf("stock after 270 = %s, want 0", q)
	}

	// dispatch board: first dispatch queue 1, reassignment appends queue 2 + audit
	v1, err := distSvc.CreateVehicle(ctx, distribution.VehicleInput{PlateNumber: unique("PLATE"), VehicleName: "Truck A"})
	if err != nil {
		t.Fatalf("vehicle 1: %v", err)
	}
	v2, err := distSvc.CreateVehicle(ctx, distribution.VehicleInput{PlateNumber: unique("PLATE"), VehicleName: "Truck B"})
	if err != nil {
		t.Fatalf("vehicle 2: %v", err)
	}
	if _, err := distSvc.CreateDispatch(ctx, do.ID, distribution.DispatchInput{ShipmentID: sh1.ID, FleetID: v1.ID, Priority: distribution.PriorityHigh}, op); err != nil {
		t.Fatalf("dispatch 1: %v", err)
	}
	if _, err := distSvc.CreateDispatch(ctx, do.ID, distribution.DispatchInput{ShipmentID: sh2.ID, FleetID: v2.ID, Priority: distribution.PriorityUrgent}, op); err != nil {
		t.Fatalf("dispatch 2: %v", err)
	}
	do, err = distSvc.GetDeliveryOrder(ctx, do.ID)
	if err != nil {
		t.Fatal(err)
	}
	if len(do.Dispatches) != 2 || do.Dispatches[0].QueueOrder != 1 || do.Dispatches[1].QueueOrder != 2 {
		t.Fatalf("dispatches = %+v", do.Dispatches)
	}
	var hist int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM delivery_status_history WHERE delivery_order_id=$1 AND reference_type='dispatch'`, do.ID).Scan(&hist); err != nil {
		t.Fatal(err)
	}
	if hist < 2 {
		t.Fatalf("dispatch audit rows = %d, want >= 2", hist)
	}

	// delivery closes the DO only when every shipment is delivered
	if _, err := distSvc.DeliverShipment(ctx, sh1.ID, op); err != nil {
		t.Fatalf("DeliverShipment(500): %v", err)
	}
	do, err = distSvc.GetDeliveryOrder(ctx, do.ID)
	if err != nil {
		t.Fatal(err)
	}
	if do.Status != distribution.StatusDelivered || do.DeliveredQuantity.String() != "500" {
		t.Fatalf("DO after delivery 500 = %s delivered %s", do.Status, do.DeliveredQuantity)
	}
	if _, err := distSvc.DeliverShipment(ctx, sh2.ID, op); err != nil {
		t.Fatalf("DeliverShipment(270): %v", err)
	}
	do, err = distSvc.GetDeliveryOrder(ctx, do.ID)
	if err != nil {
		t.Fatal(err)
	}
	if do.Status != distribution.StatusCompleted || do.DeliveredQuantity.String() != "770" {
		t.Fatalf("DO after delivery 270 = %s delivered %s", do.Status, do.DeliveredQuantity)
	}

	// shipment movements recorded against the shipment references
	var mov int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM stock_movements WHERE reference_type='shipment'`).Scan(&mov); err != nil {
		t.Fatal(err)
	}
	if mov != 2 {
		t.Fatalf("shipment movements = %d, want 2", mov)
	}

	// numbers follow the monthly formats
	if sh1.ShipmentNumber == sh2.ShipmentNumber || sh1.ShipmentNumber == do.DoNumber {
		t.Fatalf("distinct numbers required: do=%s sh1=%s sh2=%s", do.DoNumber, sh1.ShipmentNumber, sh2.ShipmentNumber)
	}
}

// TestShipmentTrackingGate is the Phase 5 gate: shipments carry barcode +
// tracking identifiers, tracking events are append-only with range validation
// and provider idempotency, lookups resolve every identifier family without
// ambiguity, and the summary never claims real-time without an active provider.
func TestShipmentTrackingGate(t *testing.T) {
	pool := testPool(t)
	setUp(t, pool)
	ctx := context.Background()

	cust := seedCustomer(t, pool)
	wh := seedWarehouse(t, pool)
	cat := seedCategory(t, pool)
	unit := seedUnit(t, pool)
	p := seedProduct(t, pool, cat, unit, "100")
	seedStock(t, pool, p.ID, wh, "770")
	op := seedUser(t, pool)

	invSvc := inventory.NewService(inventory.NewRepository(pool), seqAdapter{pool}, wib)
	salesSvc := salesService(pool, invSvc)
	distSvc := distributionService(pool, invSvc) // no LocationProvider wired

	so, err := salesSvc.Create(ctx, sales.SOInput{
		CustomerID:  cust,
		WarehouseID: &wh,
		OrderDate:   time.Now().In(wib).Format("2006-01-02"),
		Items:       []sales.SOItemInput{{ProductID: p.ID, Quantity: decimal.NewFromInt(770), UnitPrice: decimal.NewFromInt(100)}},
	})
	if err != nil {
		t.Fatalf("SO create: %v", err)
	}
	if _, err := salesSvc.Confirm(ctx, so.ID); err != nil {
		t.Fatalf("SO confirm: %v", err)
	}
	do, err := distSvc.CreateDeliveryOrder(ctx, distribution.DOInput{
		SalesOrderID: so.ID,
		Items:        []distribution.DOItemInput{{ProductID: p.ID, RequestedQuantity: decimal.NewFromInt(770)}},
	}, op)
	if err != nil {
		t.Fatalf("CreateDeliveryOrder: %v", err)
	}

	sh1, err := distSvc.CreateShipment(ctx, do.ID, distribution.ShipmentInput{
		Items: []distribution.ShipmentItemInput{{ProductID: p.ID, ShippedQuantity: decimal.NewFromInt(500)}},
	}, op)
	if err != nil {
		t.Fatalf("CreateShipment(500): %v", err)
	}
	sh2, err := distSvc.CreateShipment(ctx, do.ID, distribution.ShipmentInput{
		Items: []distribution.ShipmentItemInput{{ProductID: p.ID, ShippedQuantity: decimal.NewFromInt(270)}},
	}, op)
	if err != nil {
		t.Fatalf("CreateShipment(270): %v", err)
	}

	// identifiers persisted and collision-free across families
	for _, sh := range []*distribution.Shipment{sh1, sh2} {
		if sh.TrackingNumber == "" || sh.Barcode == "" {
			t.Fatalf("shipment %d missing identifiers: %+v", sh.ID, sh)
		}
		var dbBarcode, dbTracking, dbShip string
		if err := pool.QueryRow(ctx,
			`SELECT barcode, tracking_number, shipment_number FROM shipments WHERE id=$1`, sh.ID).
			Scan(&dbBarcode, &dbTracking, &dbShip); err != nil {
			t.Fatal(err)
		}
		if dbBarcode != sh.Barcode || dbTracking != sh.TrackingNumber || dbShip != sh.ShipmentNumber {
			t.Fatalf("db identifiers mismatch: %s %s %s", dbBarcode, dbTracking, dbShip)
		}
		if sh.Barcode == sh.TrackingNumber || sh.Barcode == sh.ShipmentNumber {
			t.Fatalf("barcode %s collides with another identifier family", sh.Barcode)
		}
	}
	if sh1.Barcode == sh2.Barcode || sh1.TrackingNumber == sh2.TrackingNumber {
		t.Fatal("identifiers must be unique per shipment")
	}

	// lookup resolves every family unambiguously
	lookups := []struct {
		q         string
		matchedBy string
		wantID    int64
	}{
		{sh1.ShipmentNumber, "shipment_number", sh1.ID},
		{sh1.TrackingNumber, "tracking_number", sh1.ID},
		{sh1.Barcode, "barcode", sh1.ID},
		{sh2.Barcode, "barcode", sh2.ID},
	}
	for _, tc := range lookups {
		res, err := distSvc.LookupShipment(ctx, tc.q)
		if err != nil {
			t.Fatalf("lookup %s: %v", tc.q, err)
		}
		if res.MatchedBy != tc.matchedBy || len(res.Shipments) != 1 || res.Shipments[0].Shipment.ID != tc.wantID {
			t.Fatalf("lookup %s = %s count %d want %d", tc.q, res.MatchedBy, len(res.Shipments), tc.wantID)
		}
	}
	// DO lookup returns every shipment under that DO
	res, err := distSvc.LookupShipment(ctx, do.DoNumber)
	if err != nil {
		t.Fatalf("do lookup: %v", err)
	}
	if len(res.Shipments) != 2 {
		t.Fatalf("do lookup shipments = %d, want 2", len(res.Shipments))
	}

	// tracking events: append-only with coordinates, validation, idempotency
	lat, lng, acc := -6.2, 106.8, 12.5
	ref := "tsp-001"
	ev1, err := distSvc.RecordTrackingEvent(ctx, sh1.ID, distribution.TrackingEventInput{
		Status:      distribution.ShipmentPending,
		Source:      distribution.TrackingSourceThirdParty,
		Latitude:    &lat,
		Longitude:   &lng,
		AccuracyM:   &acc,
		ProviderRef: &ref,
	}, op)
	if err != nil {
		t.Fatalf("record event: %v", err)
	}
	// duplicate provider_ref returns the existing event, no second row
	ev2, err := distSvc.RecordTrackingEvent(ctx, sh1.ID, distribution.TrackingEventInput{
		Status:      distribution.ShipmentInTransit,
		Source:      distribution.TrackingSourceThirdParty,
		ProviderRef: &ref,
	}, op)
	if err != nil {
		t.Fatalf("duplicate event: %v", err)
	}
	if ev1.ID != ev2.ID {
		t.Fatalf("duplicate provider_ref created new event %d != %d", ev1.ID, ev2.ID)
	}
	var evCount int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM shipment_tracking_events WHERE shipment_id=$1`, sh1.ID).Scan(&evCount); err != nil {
		t.Fatal(err)
	}
	if evCount != 1 {
		t.Fatalf("tracking events = %d, want 1 after dedupe", evCount)
	}

	// range validation rejected without writes
	badLat := 91.0
	if _, err := distSvc.RecordTrackingEvent(ctx, sh1.ID, distribution.TrackingEventInput{
		Status: distribution.ShipmentInTransit, Source: distribution.TrackingSourceManual,
		Latitude: &badLat, Longitude: &lng,
	}, op); !errors.Is(err, distribution.ErrTrackingCoordsInvalid) {
		t.Fatalf("out-of-range coords err = %v, want ErrTrackingCoordsInvalid", err)
	}

	// summary: history present, latest from events, no live claims without provider
	sum, err := distSvc.GetShipmentTracking(ctx, sh1.ID)
	if err != nil {
		t.Fatalf("GetShipmentTracking: %v", err)
	}
	if sum.RealTime.Available {
		t.Fatal("real_time.available must be false without a location provider")
	}
	if sum.Latest == nil || sum.Latest.IsLive || sum.Latest.Source != distribution.TrackingSourceThirdParty ||
		sum.Latest.Latitude != lat || sum.Latest.Longitude != lng {
		t.Fatalf("latest = %+v, want historical third-party point", sum.Latest)
	}
	if len(sum.History) != 1 {
		t.Fatalf("history = %d events, want 1", len(sum.History))
	}
	if sum.Shipment.ShipmentNumber != sh1.ShipmentNumber {
		t.Fatalf("summary shipment = %s, want %s", sum.Shipment.ShipmentNumber, sh1.ShipmentNumber)
	}

	// empty shipment has no latest point
	sum2, err := distSvc.GetShipmentTracking(ctx, sh2.ID)
	if err != nil {
		t.Fatalf("GetShipmentTracking sh2: %v", err)
	}
	if sum2.Latest != nil || len(sum2.History) != 0 {
		t.Fatalf("sh2 summary latest=%+v history=%d, want nil/0", sum2.Latest, len(sum2.History))
	}

	// unknown identifiers are not found
	if _, err := distSvc.LookupShipment(ctx, "DOES-NOT-EXIST"); !errors.Is(err, distribution.ErrNotFound) {
		t.Fatalf("garbage lookup err = %v, want ErrNotFound", err)
	}
}

func pad(n int64) string {
	s := "0000" + itoa(n)
	return s[len(s)-4:]
}

func itoa(v int64) string {
	b := [20]byte{}
	i := len(b)
	for {
		i--
		b[i] = byte('0' + v%10)
		v /= 10
		if v == 0 {
			break
		}
	}
	return string(b[i:])
}
