package cashier

import (
	"context"
	"errors"
	"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/auth"
	"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 {
	shifts map[int64]db.CashierShift
	openBy map[int64]int64 // cashierID -> open shift id
	nextID int64
}

func newFakeStore() *fakeStore {
	return &fakeStore{shifts: map[int64]db.CashierShift{}, openBy: map[int64]int64{}}
}

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

func (f *fakeStore) CreateShift(_ context.Context, _ db.DBTX, p db.CreateCashierShiftParams) (db.CashierShift, error) {
	f.nextID++
	row := db.CashierShift{
		ID:          f.nextID,
		ShiftNumber: p.ShiftNumber,
		CashierID:   p.CashierID,
		Status:      "open",
		OpeningCash: p.OpeningCash,
		Notes:       p.Notes,
		StoreID:     p.StoreID,
		CreatedAt:   pgtype.Timestamptz{Time: time.Now(), Valid: true},
		UpdatedAt:   pgtype.Timestamptz{Time: time.Now(), Valid: true},
	}
	f.shifts[row.ID] = row
	f.openBy[p.CashierID] = row.ID
	return row, nil
}

func (f *fakeStore) SetShiftBinding(_ context.Context, _ db.DBTX, p db.SetShiftBindingParams) (db.CashierShift, error) {
	row, ok := f.shifts[p.ID]
	if !ok {
		return db.CashierShift{}, ErrNotFound
	}
	row.RegisterID = p.RegisterID
	row.StoreID = p.StoreID
	f.shifts[p.ID] = row
	return row, nil
}

func (f *fakeStore) GetShiftByID(_ context.Context, _ db.DBTX, id int64) (db.CashierShift, error) {
	return f.get(id)
}
func (f *fakeStore) GetShiftByIDForUpdate(_ context.Context, _ db.DBTX, id int64) (db.CashierShift, error) {
	return f.get(id)
}
func (f *fakeStore) get(id int64) (db.CashierShift, error) {
	row, ok := f.shifts[id]
	if !ok {
		return db.CashierShift{}, ErrNotFound
	}
	return row, nil
}

func (f *fakeStore) GetOpenShiftByCashier(_ context.Context, _ db.DBTX, cashierID int64) (db.CashierShift, error) {
	id, ok := f.openBy[cashierID]
	if !ok {
		return db.CashierShift{}, ErrNotFound
	}
	return f.get(id)
}
func (f *fakeStore) GetOpenShiftByCashierForUpdate(_ context.Context, _ db.DBTX, cashierID int64) (db.CashierShift, error) {
	return f.GetOpenShiftByCashier(context.Background(), nil, cashierID)
}

func (f *fakeStore) CloseShift(_ context.Context, _ db.DBTX, id int64, actualCash, variance decimal.Decimal, notes *string) error {
	row, ok := f.shifts[id]
	if !ok {
		return ErrNotFound
	}
	row.Status = "closed"
	row.ActualCash = decimal.NullDecimal{Decimal: actualCash, Valid: true}
	row.Variance = decimal.NullDecimal{Decimal: variance, Valid: true}
	row.ClosedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true}
	if notes != nil && *notes != "" {
		if row.Notes.Valid && row.Notes.String != "" {
			row.Notes = pgtype.Text{String: row.Notes.String + "\n" + *notes, Valid: true}
		} else {
			row.Notes = pgtype.Text{String: *notes, Valid: true}
		}
	}
	f.shifts[id] = row
	return nil
}

func (f *fakeStore) ListShifts(context.Context, db.DBTX, db.ListCashierShiftsParams) ([]db.ListCashierShiftsRow, error) {
	out := make([]db.ListCashierShiftsRow, 0, len(f.shifts))
	for _, s := range f.shifts {
		// sqlc flattens CashierShift into the row rather than embedding it, so
		// the fake copies each field across instead of assigning the struct.
		out = append(out, db.ListCashierShiftsRow{
			ID:          s.ID,
			ShiftNumber: s.ShiftNumber,
			CashierID:   s.CashierID,
			Status:      s.Status,
			OpenedAt:    s.OpenedAt,
			OpeningCash: s.OpeningCash,
			ClosedAt:    s.ClosedAt,
			ActualCash:  s.ActualCash,
			Variance:    s.Variance,
			Notes:       s.Notes,
			RegisterID:  s.RegisterID,
			StoreID:     s.StoreID,
			CreatedAt:   s.CreatedAt,
			UpdatedAt:   s.UpdatedAt,
			CashierName: "Kasir " + s.ShiftNumber,
		})
	}
	return out, nil
}
func (f *fakeStore) CountShifts(context.Context, db.DBTX, db.CountCashierShiftsParams) (int64, error) {
	return int64(len(f.shifts)), nil
}
func (f *fakeStore) CreateDrawerEntry(context.Context, db.DBTX, db.CreateDrawerEntryParams) (db.CashierDrawerEntry, error) {
	return db.CashierDrawerEntry{}, nil
}
func (f *fakeStore) ListDrawerEntries(context.Context, db.DBTX, int64) ([]db.CashierDrawerEntry, error) {
	return nil, nil
}
func (f *fakeStore) SumDrawerByShift(context.Context, db.DBTX, int64) ([]db.SumDrawerByShiftRow, error) {
	return nil, nil
}
func (f *fakeStore) SumDrawerByShifts(context.Context, db.DBTX, []int64) ([]db.SumDrawerByShiftsRow, error) {
	return nil, nil
}
func (f *fakeStore) SumCashSalesByShift(context.Context, db.DBTX, int64) (decimal.Decimal, error) {
	return decimal.Zero, nil
}
func (f *fakeStore) SumNonCashSalesByShift(context.Context, db.DBTX, int64) (decimal.Decimal, error) {
	return decimal.Zero, nil
}
func (f *fakeStore) CountOrdersByShift(context.Context, db.DBTX, int64) (int64, error) {
	return 0, nil
}
func (f *fakeStore) SetSalesOrderShift(context.Context, db.DBTX, int64, int64) error {
	return nil
}

func (f *fakeStore) ListClosureAudit(context.Context, db.DBTX, int64) ([]ClosureAudit, error) {
	return []ClosureAudit{}, nil
}
func (f *fakeStore) RecordClosureAudit(context.Context, db.DBTX, int64, ClosureAudit) error {
	return nil
}

func (f *fakeStore) IsStoreActive(context.Context, db.DBTX, int64) (bool, error) {
	return true, nil
}
func (f *fakeStore) IsUserInActiveStore(context.Context, db.DBTX, int64, int64) (bool, error) {
	return true, nil
}
func (f *fakeStore) ActiveStoresForUser(context.Context, db.DBTX, int64) ([]int64, error) {
	return []int64{1}, nil
}

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

func TestOpen_AutoBindsCashierActiveStore(t *testing.T) {
	s := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	sh, err := s.Open(context.Background(), OpenInput{CashierID: 1, OpeningCash: decimal.NewFromInt(100)})
	if err != nil {
		t.Fatal(err)
	}
	if sh.RegisterID != nil {
		t.Fatalf("expected nil register, got %v", sh.RegisterID)
	}
	if sh.StoreID == nil || *sh.StoreID != 1 {
		t.Fatalf("expected store 1, got %v", sh.StoreID)
	}
	got, _ := s.Get(context.Background(), sh.ID)
	if got.StoreID == nil || *got.StoreID != 1 {
		t.Fatalf("re-fetch: expected store 1, got %v", got.StoreID)
	}
}

func TestOpen_BindsRegisterAndStore(t *testing.T) {
	fs := newFakeStore()
	s := NewService(fs, &fakeSeq{}, time.UTC)
	reg, store := int64(5), int64(7)
	sh, err := s.Open(context.Background(), OpenInput{
		CashierID: 1, OpeningCash: decimal.NewFromInt(100), RegisterID: &reg, StoreID: &store,
	})
	if err != nil {
		t.Fatal(err)
	}
	// Open returns the CreateShift row; binding is visible after re-fetch.
	got, err := s.Get(context.Background(), sh.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got.RegisterID == nil || *got.RegisterID != 5 {
		t.Fatalf("register_id = %v, want 5", got.RegisterID)
	}
	if got.StoreID == nil || *got.StoreID != 7 {
		t.Fatalf("store_id = %v, want 7", got.StoreID)
	}
}

func TestOpen_RejectsAnotherCashierID(t *testing.T) {
	// P2: any authenticated caller could attribute a shift to any cashier_id.
	// Non-admin callers may only open their own shift.
	s := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	cashierCtx := auth.ToContext(context.Background(), &auth.User{ID: 7, Role: auth.RoleCashier}, 1)
	if _, err := s.Open(cashierCtx, OpenInput{CashierID: 9, OpeningCash: decimal.NewFromInt(100)}); !errors.Is(err, ErrForbidden) {
		t.Fatalf("got %v, want ErrForbidden", err)
	}
	if _, err := s.Open(cashierCtx, OpenInput{CashierID: 7, OpeningCash: decimal.NewFromInt(100)}); err != nil {
		t.Fatalf("own shift: %v", err)
	}
	adminCtx := auth.ToContext(context.Background(), &auth.User{ID: 1, Role: auth.RoleAdmin}, 1)
	if _, err := s.Open(adminCtx, OpenInput{CashierID: 9, OpeningCash: decimal.NewFromInt(100)}); err != nil {
		t.Fatalf("admin override: %v", err)
	}
}

func TestOpen_OneOpenShiftPerCashier(t *testing.T) {
	s := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	if _, err := s.Open(context.Background(), OpenInput{CashierID: 1, OpeningCash: decimal.NewFromInt(100)}); err != nil {
		t.Fatal(err)
	}
	if _, err := s.Open(context.Background(), OpenInput{CashierID: 1, OpeningCash: decimal.NewFromInt(100)}); !errors.Is(err, ErrShiftOpen) {
		t.Fatalf("got %v, want ErrShiftOpen", err)
	}
}

type inactiveStoreFake struct {
	*fakeStore
}

func (i *inactiveStoreFake) IsStoreActive(context.Context, db.DBTX, int64) (bool, error) {
	return false, nil
}

func TestOpen_RejectsInactiveStore(t *testing.T) {
	fs := &inactiveStoreFake{fakeStore: newFakeStore()}
	s := NewService(fs, &fakeSeq{}, time.UTC)
	storeID := int64(3)
	_, err := s.Open(context.Background(), OpenInput{
		CashierID: 1, StoreID: &storeID, OpeningCash: decimal.NewFromInt(100),
	})
	if err == nil || err.Error() != "store is inactive or not found" {
		t.Fatalf("expected inactive store error, got %v", err)
	}
}

type unassignedUserFake struct {
	*fakeStore
}

func (u *unassignedUserFake) IsUserInActiveStore(context.Context, db.DBTX, int64, int64) (bool, error) {
	return false, nil
}

func TestOpen_RejectsUserNotAssignedToStore(t *testing.T) {
	fs := &unassignedUserFake{fakeStore: newFakeStore()}
	s := NewService(fs, &fakeSeq{}, time.UTC)
	storeID := int64(1)
	// Even for Admin caller, non-assigned user is rejected
	adminCtx := auth.ToContext(context.Background(), &auth.User{ID: 1, Role: auth.RoleAdmin}, 1)
	_, err := s.Open(adminCtx, OpenInput{
		CashierID: 1, StoreID: &storeID, OpeningCash: decimal.NewFromInt(100),
	})
	if !errors.Is(err, ErrForbidden) {
		t.Fatalf("expected ErrForbidden for unassigned user/admin, got %v", err)
	}
}
