package galon

import (
	"context"
	"strings"
	"testing"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/shopspring/decimal"

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

// --- fake tx -----------------------------------------------------------------

type fakeTx struct{}

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

// --- fake seq ----------------------------------------------------------------

type fakeSeq struct{ n int64 }

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

// --- fake store --------------------------------------------------------------

type fakeStore struct {
	txs        map[int64]db.GalonTransaction
	nextID     int64
	custExists bool
	prodExists bool
	// invRow / invAllowedStores let a test drive InventoryStatus and observe
	// the store scope it received.
	invRow           db.GalonInventoryStatusRow
	invAllowedStores []int64
}

func newFakeStore() *fakeStore {
	return &fakeStore{txs: map[int64]db.GalonTransaction{}, custExists: true, prodExists: true}
}

func (f *fakeStore) Begin(context.Context) (Tx, error) { return fakeTx{}, nil }

func (f *fakeStore) CreateTransaction(_ context.Context, _ db.DBTX, p db.CreateGalonTransactionParams) (db.GalonTransaction, error) {
	f.nextID++
	row := db.GalonTransaction{
		ID:                f.nextID,
		TransactionNumber: p.TransactionNumber,
		CustomerID:        p.CustomerID,
		Type:              p.Type,
		FulfillmentMode:   p.FulfillmentMode,
		ProductID:         p.ProductID,
		Quantity:          p.Quantity,
		UnitPrice:         p.UnitPrice,
		DepositAmount:     p.DepositAmount,
		LoanAmount:        decimal.Zero,
		LoanReturned:      false,
		Status:            p.Status,
		Notes:             p.Notes,
		CreatedBy:         p.CreatedBy,
		CreatedAt:         pgtype.Timestamptz{Time: time.Now(), Valid: true},
		UpdatedAt:         pgtype.Timestamptz{Time: time.Now(), Valid: true},
	}
	f.txs[row.ID] = row
	return row, nil
}

func (f *fakeStore) SetGalonLoan(_ context.Context, _ db.DBTX, p db.SetGalonLoanParams) (db.GalonTransaction, error) {
	row, ok := f.txs[p.ID]
	if !ok {
		return db.GalonTransaction{}, ErrNotFound
	}
	row.LoanAmount = p.LoanAmount
	row.LoanReturned = p.LoanReturned
	f.txs[p.ID] = row
	return row, nil
}

func (f *fakeStore) GetByID(_ context.Context, _ db.DBTX, id int64) (db.GalonTransaction, error) {
	return f.get(id)
}
func (f *fakeStore) GetByIDForUpdate(_ context.Context, _ db.DBTX, id int64) (db.GalonTransaction, error) {
	return f.get(id)
}
func (f *fakeStore) get(id int64) (db.GalonTransaction, error) {
	row, ok := f.txs[id]
	if !ok {
		return db.GalonTransaction{}, ErrNotFound
	}
	return row, nil
}

func (f *fakeStore) SetStatus(_ context.Context, _ db.DBTX, id int64, status string) error {
	row, ok := f.txs[id]
	if !ok {
		return ErrNotFound
	}
	row.Status = status
	f.txs[id] = row
	return nil
}

func (f *fakeStore) List(context.Context, db.DBTX, db.ListGalonTransactionsParams) ([]db.GalonTransaction, error) {
	out := make([]db.GalonTransaction, 0, len(f.txs))
	for _, t := range f.txs {
		out = append(out, t)
	}
	return out, nil
}
func (f *fakeStore) Count(context.Context, db.DBTX, db.CountGalonTransactionsParams) (int64, error) {
	return int64(len(f.txs)), nil
}
func (f *fakeStore) CustomerExists(context.Context, db.DBTX, int64) (bool, error) {
	return f.custExists, nil
}
func (f *fakeStore) ProductExists(context.Context, db.DBTX, int64) (bool, error) {
	return f.prodExists, nil
}

// invStatus is what InventoryStatus will hand back, so tests can pin the
// handler contract (which fields are computed, and that diToko stays null)
// without a database.
func (f *fakeStore) InventoryStatus(_ context.Context, _ db.DBTX, allowedStoreIDs []int64) (db.GalonInventoryStatusRow, error) {
	f.invAllowedStores = allowedStoreIDs
	return f.invRow, nil
}

func (f *fakeStore) GallonInfoByCode(_ context.Context, _ db.DBTX, code string, storeIDs []int64) (*GallonInfo, error) {
	n := int64(0)
	var last *time.Time
	for _, t := range f.txs {
		if t.GallonCode.Valid && strings.EqualFold(t.GallonCode.String, code) {
			n++
			tt := t.CreatedAt.Time
			if last == nil || tt.After(*last) {
				last = &tt
			}
		}
	}
	return &GallonInfo{Code: code, RefillCount: n, LastRefillAt: last}, nil
}
func (f *fakeStore) TransactionsByCode(_ context.Context, _ db.DBTX, code string, storeIDs []int64) ([]db.GalonTransaction, error) {
	out := []db.GalonTransaction{}
	for _, t := range f.txs {
		if t.GallonCode.Valid && strings.EqualFold(t.GallonCode.String, code) {
			out = append(out, t)
		}
	}
	return out, nil
}

// --- tests --------------------------------------------------------------------

func TestCreate_BorrowRecordsLoan(t *testing.T) {
	s := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	deposit := decimal.NewFromInt(5000)
	tr, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeBorrowGalon, FulfillmentMode: FulfillmentPickup,
		Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(0), DepositAmount: deposit,
	})
	if err != nil {
		t.Fatal(err)
	}
	if tr.Type != TypeBorrowGalon {
		t.Fatalf("type = %q, want borrow_galon", tr.Type)
	}
	if !tr.LoanAmount.Equal(deposit) {
		t.Fatalf("loan_amount = %s, want %s", tr.LoanAmount, deposit)
	}
	if tr.LoanReturned {
		t.Fatal("loan should not be returned yet")
	}
}

func TestReturnGalon_MarksLoanReturned(t *testing.T) {
	fs := newFakeStore()
	s := NewService(fs, &fakeSeq{}, time.UTC)
	deposit := decimal.NewFromInt(5000)
	tr, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeBorrowGalon, FulfillmentMode: FulfillmentPickup,
		Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(0), DepositAmount: deposit,
	})
	if err != nil {
		t.Fatal(err)
	}
	r, err := s.ReturnGalon(context.Background(), tr.ID)
	if err != nil {
		t.Fatal(err)
	}
	if !r.LoanReturned {
		t.Fatal("loan_returned should be true after return")
	}
	// second return must error
	if _, err := s.ReturnGalon(context.Background(), tr.ID); err == nil {
		t.Fatal("expected error on double return")
	}
}

func TestReturnGalon_WritesDepositRefund(t *testing.T) {
	// Liability ruling: the deposit collected at Complete must come back as
	// a refund row on return — otherwise collected deposits accumulate with
	// no clearing path.
	fs := newFakeStore()
	pay := &fakePayments{}
	s := NewService(fs, &fakeSeq{}, time.UTC).WithPayments(pay)
	tr, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeBorrowGalon, FulfillmentMode: FulfillmentPickup,
		Quantity: decimal.NewFromInt(1), UnitPrice: decimal.Zero, DepositAmount: decimal.NewFromInt(35000),
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Complete(context.Background(), tr.ID); err != nil {
		t.Fatal(err)
	}
	if _, err := s.ReturnGalon(context.Background(), tr.ID); err != nil {
		t.Fatal(err)
	}
	if len(pay.rows) != 2 {
		t.Fatalf("payment rows = %d, want 2 (deposit collect at Complete + refund at Return)", len(pay.rows))
	}
	got := pay.rows[1]
	if got.PayableType != GalonDepositPayableType {
		t.Errorf("payable_type = %q, want %q", got.PayableType, GalonDepositPayableType)
	}
	if !got.Amount.Equal(decimal.NewFromInt(-35000)) {
		t.Errorf("amount = %s, want -35000", got.Amount)
	}
	if !pay.rows[0].Amount.Equal(decimal.NewFromInt(35000)) {
		t.Errorf("collect amount = %s, want 35000", pay.rows[0].Amount)
	}
}

func TestReturnGalon_RejectsNonBorrow(t *testing.T) {
	s := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	deposit := decimal.NewFromInt(0)
	tr, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeRefill, FulfillmentMode: FulfillmentPickup,
		Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(1000), DepositAmount: deposit,
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.ReturnGalon(context.Background(), tr.ID); err == nil {
		t.Fatal("expected error returning a non-borrow transaction")
	}
}

// --- money-integrity tests (bug sweep 2026-09) -------------------------------

type fakePayments struct {
	rows []db.CreatePaymentParams
}

func (f *fakePayments) CreatePayment(_ context.Context, _ db.DBTX, p db.CreatePaymentParams) (int64, error) {
	f.rows = append(f.rows, p)
	return int64(len(f.rows)), nil
}

func testPriceList() PriceList {
	return PriceList{HargaIsi: 6000, HargaGalon: 50000, Deposit: 35000, BiayaAntar: 5000}
}

func TestCreate_OverwritesClientPriceWithServerList(t *testing.T) {
	s := NewService(newFakeStore(), &fakeSeq{}, time.UTC).WithPrices(testPriceList)
	// Client sends a manipulated price; the stored row must carry the server price.
	tr, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeRefill, FulfillmentMode: FulfillmentPickup,
		Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(1), DepositAmount: decimal.Zero,
	})
	if err != nil {
		t.Fatal(err)
	}
	if !tr.UnitPrice.Equal(decimal.NewFromInt(6000)) {
		t.Fatalf("unit_price = %s, want 6000 (server list)", tr.UnitPrice)
	}
	// Delivery adds biaya antar.
	tr2, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeRefill, FulfillmentMode: FulfillmentDelivery,
		Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(1), DepositAmount: decimal.Zero,
	})
	if err != nil {
		t.Fatal(err)
	}
	if !tr2.UnitPrice.Equal(decimal.NewFromInt(11000)) {
		t.Fatalf("unit_price = %s, want 11000 (isi + antar)", tr2.UnitPrice)
	}
}

func TestComplete_WritesPaymentRow(t *testing.T) {
	fs := newFakeStore()
	pay := &fakePayments{}
	s := NewService(fs, &fakeSeq{}, time.UTC).WithPrices(testPriceList).WithPayments(pay)
	tr, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeRefill, FulfillmentMode: FulfillmentPickup,
		Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(999), DepositAmount: decimal.Zero,
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Complete(context.Background(), tr.ID); err != nil {
		t.Fatal(err)
	}
	if len(pay.rows) != 1 {
		t.Fatalf("payments written = %d, want 1", len(pay.rows))
	}
	got := pay.rows[0]
	if got.PayableType != GalonPayableType || got.PayableID != tr.ID {
		t.Errorf("payable = %s/%d, want galon/%d", got.PayableType, got.PayableID, tr.ID)
	}
	if !got.Amount.Equal(decimal.NewFromInt(12000)) {
		t.Errorf("amount = %s, want 12000 (2 x server 6000)", got.Amount)
	}
	if got.PaymentMethod != "cash" {
		t.Errorf("method = %q, want cash", got.PaymentMethod)
	}
	// Second complete must fail (status gate) and post nothing more.
	if _, err := s.Complete(context.Background(), tr.ID); err == nil {
		t.Fatal("expected error on double complete")
	}
	if len(pay.rows) != 1 {
		t.Fatalf("payments written = %d, want still 1", len(pay.rows))
	}
}

// This used to assert that a borrow wrote NO payment at all, with the reason
// spelled out: "borrow has no price; deposit is liability, TODO §20 #3". It was
// a placeholder for a decision that had not been made yet, encoding the
// absence as the expectation.
//
// The ruling has since come back — a deposit is a liability and is recorded —
// so the zero-PRICE rule is what remains true: no GalonPayableType row when
// there is nothing priced. The deposit row is asserted separately in
// TestComplete_WritesDepositAsSeparateLiability, which is the behaviour that
// replaced the gap this test stood in for.
func TestComplete_ZeroAmountWritesNoPayment(t *testing.T) {
	fs := newFakeStore()
	pay := &fakePayments{}
	s := NewService(fs, &fakeSeq{}, time.UTC).WithPrices(testPriceList).WithPayments(pay)
	tr, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeBorrowGalon, FulfillmentMode: FulfillmentPickup,
		Quantity: decimal.NewFromInt(1), UnitPrice: decimal.Zero, DepositAmount: decimal.NewFromInt(35000),
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Complete(context.Background(), tr.ID); err != nil {
		t.Fatal(err)
	}
	for _, r := range pay.rows {
		if r.PayableType == GalonPayableType {
			t.Fatalf("wrote a %s row of %s for a borrow with no price", GalonPayableType, r.Amount)
		}
	}
}

// The deposit is customer money the store holds and returns, not earnings. It
// was stored on the transaction and then deliberately excluded from the money
// movement while its liability semantics were pending a business ruling
// (§20 #3). That ruling came back "record it as a liability", and this is it.
//
// It is a SEPARATE row, under its own payable_type, in the same transaction as
// the status flip. Recording it under GalonPayableType alongside the sale would
// make cash-in indistinguishable from money earned, so any report grouped by
// payable would overstate what the store took and lose the outstanding
// liability entirely.
func TestComplete_WritesDepositAsSeparateLiability(t *testing.T) {
	fs := newFakeStore()
	pay := &fakePayments{}
	s := NewService(fs, &fakeSeq{}, time.UTC).WithPrices(testPriceList).WithPayments(pay)

	deposit := decimal.NewFromInt(35000)
	tr, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeBorrowGalon, FulfillmentMode: FulfillmentPickup,
		Quantity: decimal.NewFromInt(1), UnitPrice: decimal.Zero, DepositAmount: deposit,
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Complete(context.Background(), tr.ID); err != nil {
		t.Fatal(err)
	}

	var depositRow, saleRow *db.CreatePaymentParams
	for i := range pay.rows {
		switch pay.rows[i].PayableType {
		case GalonDepositPayableType:
			depositRow = &pay.rows[i]
		case GalonPayableType:
			saleRow = &pay.rows[i]
		}
	}
	if depositRow == nil {
		t.Fatalf("no %s row written; payments = %+v", GalonDepositPayableType, pay.rows)
	}
	if !depositRow.Amount.Equal(deposit) {
		t.Errorf("deposit amount = %s, want %s", depositRow.Amount, deposit)
	}
	if depositRow.PayableID != tr.ID {
		t.Errorf("deposit payable_id = %d, want %d — the pair must be findable together", depositRow.PayableID, tr.ID)
	}
	// A borrow with no item price must NOT also write a zero sale row.
	if saleRow != nil {
		t.Errorf("wrote a %s row of %s for a zero-priced borrow", GalonPayableType, saleRow.Amount)
	}
}

// A zero deposit is the common case and must stay silent — no zero rows in a
// payments table that statements sum.
func TestComplete_NoDepositWritesNoLiabilityRow(t *testing.T) {
	fs := newFakeStore()
	pay := &fakePayments{}
	s := NewService(fs, &fakeSeq{}, time.UTC).WithPrices(testPriceList).WithPayments(pay)

	tr, err := s.Create(context.Background(), TransactionInput{
		CustomerID: 1, Type: TypeRefill, FulfillmentMode: FulfillmentPickup,
		Quantity: decimal.NewFromInt(1), UnitPrice: decimal.NewFromInt(6000), DepositAmount: decimal.Zero,
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Complete(context.Background(), tr.ID); err != nil {
		t.Fatal(err)
	}
	if len(pay.rows) != 1 || pay.rows[0].PayableType != GalonPayableType {
		t.Fatalf("payments = %+v, want only the sale row", pay.rows)
	}
}
