package repositories

import (
	"testing"
	"time"
)

// The terminal holds a unique index on barcode, so this rule decides whether a
// catalogue import succeeds at all. Measured against live data when it was
// written: 6,245 products, 93 distinct SKUs, and "1" used by 5,794 of them.
func TestPosBarcodeFallsBackToProductIdWhenTheSkuIsNotScannable(t *testing.T) {
	cases := []struct {
		name      string
		productID int
		sku       string
		want      string
	}{
		{"the SKU almost every product shares", 844, "1", "844"},
		{"blank SKU", 845, "", "845"},
		{"whitespace only", 846, "   ", "846"},
		{"too short to be a barcode", 847, "1234567", "847"},
		{"too long to be a barcode", 848, "123456789012345", "848"},
		{"not digits", 849, "SKU-ABC-123", "849"},
		{"digits with a space", 850, "1234 5678", "850"},

		// Real scannable codes are used as-is, so the day the catalogue carries
		// them scanning starts working with no code change.
		{"EAN-8", 851, "12345678", "12345678"},
		{"UPC-A", 852, "012345678905", "012345678905"},
		{"EAN-13", 853, "8901030865278", "8901030865278"},
		{"padded EAN-13", 854, "  8901030865278  ", "8901030865278"},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := posBarcode(c.productID, c.sku); got != c.want {
				t.Errorf("posBarcode(%d, %q) = %q, want %q", c.productID, c.sku, got, c.want)
			}
		})
	}
}

func TestPosBarcodesAreUniqueAcrossACatalogueOfSharedSkus(t *testing.T) {
	// The failure this exists to prevent: a whole catalogue collapsing onto one
	// barcode and the import being rejected by the terminal's unique index.
	seen := make(map[string]int)
	for id := 844; id < 844+500; id++ {
		barcode := posBarcode(id, "1")
		if first, clash := seen[barcode]; clash {
			t.Fatalf("products %d and %d both produced barcode %q", first, id, barcode)
		}
		seen[barcode] = id
	}
}

func TestRoundStockQtyNeverUnderDeducts(t *testing.T) {
	// productstocks.quantity is an integer column and a counter sells 1.5 kg of
	// onions. Rounding up keeps recorded stock at or below what is on the shelf;
	// truncating would let the shop oversell a little more with every sale.
	cases := []struct {
		quantity float64
		want     int
	}{
		{1, 1},
		{1.5, 2},
		{0.25, 1},
		{2.0, 2},
		{2.01, 3},
		{0, 1},
		{-1, 1},
	}

	for _, c := range cases {
		if got := roundStockQty(c.quantity); got != c.want {
			t.Errorf("roundStockQty(%g) = %d, want %d", c.quantity, got, c.want)
		}
	}
}

func TestLegacyOrderQtyIsUnchanged(t *testing.T) {
	// App orders have always truncated, and that behaviour is deliberately
	// preserved rather than corrected — changing it would silently alter stock
	// deduction for every order already flowing through createOrderTx.
	cases := []struct {
		quantity float64
		want     int
	}{
		{1, 1},
		{1.5, 1},
		{0.5, 1},
		{3.9, 3},
		{0, 1},
	}

	for _, c := range cases {
		if got := legacyOrderQty(c.quantity); got != c.want {
			t.Errorf("legacyOrderQty(%g) = %d, want %d", c.quantity, got, c.want)
		}
	}
}

// A catalogue revision is the terminal's memory of when it last pulled. If it
// does not survive a round trip, every pull silently becomes a full snapshot —
// or worse, a filtered result gets labelled as one and the shop's shelf empties.
func TestPosRevisionRoundTrips(t *testing.T) {
	at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)

	revision := posRevisionFor(1135, at)
	if revision != "loc1135-20260803T123045Z" {
		t.Fatalf("revision = %q, want loc1135-20260803T123045Z", revision)
	}

	got := posRevisionCutoff(1135, revision)
	if !got.Equal(at) {
		t.Errorf("cutoff = %v, want %v", got, at)
	}
}

func TestAnUnusableRevisionFallsBackToAFullSnapshot(t *testing.T) {
	// A zero cutoff means "send everything", and the caller turns that into
	// is_delta:false. Falling back the other way — answering an unreadable
	// revision with a change set — would leave a terminal permanently missing
	// every change it had not already seen, with nothing to show for it.
	cases := []struct {
		name     string
		location int
		revision string
	}{
		{"empty", 1135, ""},
		{"whitespace", 1135, "   "},
		{"no prefix", 1135, "20260803T123045Z"},
		{"malformed timestamp", 1135, "loc1135-not-a-time"},
		{"truncated timestamp", 1135, "loc1135-20260803"},
		{"another outlet's revision", 1135, "loc1097-20260803T123045Z"},
		{"prefix collision", 113, "loc1135-20260803T123045Z"},
		{"garbage", 1135, "../../etc/passwd"},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := posRevisionCutoff(c.location, c.revision); !got.IsZero() {
				t.Errorf("cutoff = %v, want zero (full snapshot) for %q", got, c.revision)
			}
		})
	}
}

func TestAnOutletCannotReplayAnotherOutletsRevision(t *testing.T) {
	// loc1135 and loc113 share a textual prefix. Matching loosely would let one
	// shop's cutoff silently scope another shop's delta.
	at := time.Date(2026, 8, 3, 12, 30, 45, 0, time.UTC)
	revision := posRevisionFor(1135, at)

	if got := posRevisionCutoff(1135, revision); got.IsZero() {
		t.Error("the issuing outlet could not read back its own revision")
	}
	for _, other := range []int{113, 11350, 1097, 1} {
		if got := posRevisionCutoff(other, revision); !got.IsZero() {
			t.Errorf("outlet %d accepted outlet 1135's revision (cutoff %v)", other, got)
		}
	}
}

// The bug this covers reached production and stayed invisible for a day.
//
// The MQTT consumer backfills a missing terminal code from the topic, but it
// wrote it onto the *batch* while the row was built from the *order*, so the
// two never met. Bills arriving over HTTP had no topic to fall back on at all.
// The result: 16 of 17 live bills carried an empty terminalid while their own
// invoice numbers read INV-2608-T5EDD-000NN, and `byterminal` on the sales
// summary grouped almost everything under "".
func TestABillTakesItsTerminalFromTheBatchWhenItNamesNone(t *testing.T) {
	cases := []struct {
		name          string
		orderTerminal string
		batchTerminal string
		want          string
	}{
		{"bill names its own", "T5EDD", "TOTHER", "T5EDD"},
		{"bill is silent, batch knows", "", "T5EDD", "T5EDD"},
		{"neither knows", "", "", ""},

		// Whitespace is not a terminal code. Treating it as one would file
		// bills under a blank that reads identically to the missing value
		// this fallback exists to prevent.
		{"bill sends whitespace", "   ", "T5EDD", "T5EDD"},
		{"batch sends whitespace", "", "  ", ""},
		{"codes are trimmed", " T5EDD ", "", "T5EDD"},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := posTerminalFor(c.orderTerminal, c.batchTerminal); got != c.want {
				t.Errorf("posTerminalFor(%q, %q) = %q, want %q",
					c.orderTerminal, c.batchTerminal, got, c.want)
			}
		})
	}
}

// billedat and businessdate are derived from the same parsed value and pull in
// opposite directions, so they are tested together.
//
// Live bill INV-2608-T5EDD-00116 carried billedat 2026-08-05T12:49:28Z beside
// receivedat 2026-08-05T07:19:28Z — the sale appearing to happen five and a
// half hours after it was received. The till was sending a naive local
// timestamp and time.Parse fills that silence with UTC, so a Coimbatore wall
// clock was recorded as though read in London.
//
// The daily figures survived it by luck: businessdate comes off the wall clock
// either way, and the wall clock was always the till's own. Anything comparing
// billedat against real time did not.
func TestASaleDateKeepsBothTheInstantAndTheTradingDay(t *testing.T) {
	// Coimbatore, late enough that UTC has not yet rolled into the same day.
	const ist = "2026-08-05T00:30:00+05:30"

	at, err := parsePosSaleDate(ist)
	if err != nil {
		t.Fatalf("parsePosSaleDate(%q) errored: %v", ist, err)
	}

	// The instant. 00:30 IST is 19:00 UTC the previous evening.
	wantInstant := time.Date(2026, 8, 4, 19, 0, 0, 0, time.UTC)
	if !at.UTC().Equal(wantInstant) {
		t.Errorf("instant = %v, want %v", at.UTC(), wantInstant)
	}

	// The trading day. This is the one that must NOT follow UTC — the shop rang
	// this sale on the 5th and its takings belong to the 5th. Deriving the
	// business date from UTC would file it under the 4th and leave two days
	// wrong: one short, one over.
	if got := at.Format("2006-01-02"); got != "2026-08-05" {
		t.Errorf("businessdate = %s, want 2026-08-05 — the till's own day", got)
	}
}

// Terminals built before the offset was added send a bare local timestamp, and
// they are still in the field. Parsing must not start refusing them.
//
// The instant such a bill records is wrong by the offset and cannot be
// recovered — there is nothing in the payload that says which zone it was read
// in. Its business date is still right, which is why the daily figures held up,
// and why this stays a tolerated legacy rather than a rejection.
func TestANaiveSaleDateIsStillAccepted(t *testing.T) {
	at, err := parsePosSaleDate("2026-08-05T12:49:28.245")
	if err != nil {
		t.Fatalf("a pre-offset terminal must not be refused: %v", err)
	}

	if got := at.Format("2006-01-02"); got != "2026-08-05" {
		t.Errorf("businessdate = %s, want 2026-08-05", got)
	}
}
