package cashier

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

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

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

// Service implements cashier shift use cases (§11.5).
type Service struct {
	store Store
	seq   Seq
	loc   *time.Location
}

func NewService(store Store, seq Seq, loc *time.Location) *Service {
	return &Service{store: store, seq: seq, loc: loc}
}

// checkScope rejects unbound resources for outlet-restricted callers.
func (s *Service) checkScope(ctx context.Context, storeID *int64) error {
	if !auth.StoreScopeFrom(ctx).Allows(storeID) {
		return ErrNotFound
	}
	return nil
}

func requireCashier(ctx context.Context, cashierID int64) error {
	u := auth.UserFrom(ctx)
	if u == nil || u.ID != cashierID {
		return ErrForbidden
	}
	return nil
}

func canOverride(ctx context.Context) bool {
	u := auth.UserFrom(ctx)
	return u != nil && (u.Role == auth.RoleAdmin || u.Role == auth.RoleOwner)
}

// Open starts a shift for a cashier. A cashier may hold only one open shift
// at a time (§20 #5 default).
func (s *Service) Open(ctx context.Context, in OpenInput) (*Shift, error) {
	if in.CashierID == 0 {
		return nil, errors.New("cashier_id is required")
	}
	// P2: any authenticated caller could attribute a shift to any cashier_id.
	// Non-admin callers may only open their own shift; the handler already
	// defaults an omitted id to the caller.
	if u := auth.UserFrom(ctx); u != nil && u.Role != auth.RoleAdmin && u.Role != auth.RoleOwner {
		if in.CashierID != u.ID {
			return nil, ErrForbidden
		}
	}
	if in.OpeningCash.IsNegative() {
		return nil, errors.New("opening_cash must be >= 0")
	}
	if !in.OpeningCash.Equal(in.OpeningCash.Round(2)) {
		return nil, errors.New("opening_cash must have at most 2 decimal places")
	}
	// In POS, Admin has NO special bypass: all cashier shifts MUST belong to an active store
	// and the cashier must be linked to that active store in store_users.
	if in.StoreID == nil {
		sc := auth.StoreScopeFrom(ctx)
		if len(sc.IDs()) == 1 {
			id := sc.IDs()[0]
			in.StoreID = &id
		} else {
			stores, err := s.store.ActiveStoresForUser(ctx, nil, in.CashierID)
			if err != nil {
				return nil, err
			}
			if len(stores) == 1 {
				in.StoreID = &stores[0]
			} else if len(stores) == 0 {
				return nil, ErrForbidden
			} else {
				return nil, errors.New("store_id is required: kasir terdaftar di lebih dari satu toko aktif")
			}
		}
	}
	active, err := s.store.IsStoreActive(ctx, nil, *in.StoreID)
	if err != nil {
		return nil, err
	}
	if !active {
		return nil, errors.New("store is inactive or not found")
	}
	inStore, err := s.store.IsUserInActiveStore(ctx, nil, in.CashierID, *in.StoreID)
	if err != nil {
		return nil, err
	}
	if !inStore {
		return nil, ErrForbidden
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	if _, err := s.store.GetOpenShiftByCashierForUpdate(ctx, tx, in.CashierID); err == nil {
		return nil, ErrShiftOpen
	} else if !errors.Is(err, ErrNotFound) {
		return nil, err
	}
	number, err := s.shiftCode(ctx, tx)
	if err != nil {
		return nil, err
	}
	row, err := s.store.CreateShift(ctx, tx, db.CreateCashierShiftParams{
		ShiftNumber: number,
		CashierID:   in.CashierID,
		OpeningCash: in.OpeningCash,
		Notes:       textOrNil(in.Notes),
		StoreID:     int8OrNil(in.StoreID),
	})
	if err != nil {
		return nil, err
	}
	if in.RegisterID != nil {
		if _, err := s.store.SetShiftBinding(ctx, tx, db.SetShiftBindingParams{
			ID:         row.ID,
			RegisterID: int8OrNil(in.RegisterID),
			StoreID:    int8OrNil(in.StoreID),
		}); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	sh := fromRow(row)
	if in.StoreID != nil {
		sh.StoreID = in.StoreID
	}
	return &sh, nil
}

// GetCurrentByCashier returns the open shift for a cashier, or ErrNotFound.
func (s *Service) GetCurrentByCashier(ctx context.Context, cashierID int64) (*Shift, error) {
	row, err := s.store.GetOpenShiftByCashier(ctx, nil, cashierID)
	if err != nil {
		return nil, err
	}
	if err := s.checkScope(ctx, int8ToPtr(row.StoreID)); err != nil {
		return nil, err
	}
	sh := fromRow(row)
	return &sh, nil
}

// Get returns a shift by ID.
func (s *Service) Get(ctx context.Context, id int64) (*Shift, error) {
	row, err := s.store.GetShiftByID(ctx, nil, id)
	if err != nil {
		return nil, err
	}
	if err := s.checkScope(ctx, int8ToPtr(row.StoreID)); err != nil {
		return nil, err
	}
	sh := fromRow(row)
	return &sh, nil
}

// Summary returns a shift with drawer totals and expected cash (§11.5):
// expected = opening + cash_in - cash_out + cash-method sales on the shift.
func (s *Service) Summary(ctx context.Context, id int64) (*Summary, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	return s.summary(ctx, tx, id)
}

func (s *Service) summary(ctx context.Context, tx db.DBTX, id int64) (*Summary, error) {
	row, err := s.store.GetShiftByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if err := s.checkScope(ctx, int8ToPtr(row.StoreID)); err != nil {
		return nil, err
	}
	sums, err := s.store.SumDrawerByShift(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	cashSales, err := s.store.SumCashSalesByShift(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	nonCashSales, err := s.store.SumNonCashSalesByShift(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	transactionCount, err := s.store.CountOrdersByShift(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	out := &Summary{
		Shift:            fromRow(row),
		CashSales:        cashSales,
		NonCashSales:     nonCashSales,
		TransactionCount: transactionCount,
		SalesCount:       transactionCount,
		SalesTotal:       cashSales.Add(nonCashSales),
		PaymentBreakdown: map[string]decimal.Decimal{
			"cash":     cashSales,
			"non_cash": nonCashSales,
		},
		Entries: []DrawerEntry{},
	}
	for _, r := range sums {
		switch r.Type {
		case "cash_in":
			out.CashIn = r.Total
		case "cash_out":
			out.CashOut = r.Total
		}
	}
	out.ExpectedCash = out.OpeningCash.Add(out.CashIn).Sub(out.CashOut).Add(cashSales)
	if out.Variance != nil {
		out.Difference = out.Variance
	}

	out.ClosureAudit, err = s.store.ListClosureAudit(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	drawerEntries, err := s.store.ListDrawerEntries(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if len(drawerEntries) > 0 {
		out.Entries = make([]DrawerEntry, 0, len(drawerEntries))
		for _, e := range drawerEntries {
			out.Entries = append(out.Entries, entryFromRow(e))
		}
	}
	return out, nil
}

// CashIn records a cash-in drawer entry on an open shift.
func (s *Service) CashIn(ctx context.Context, shiftID int64, in DrawerInput) (*DrawerEntry, error) {
	return s.drawerEntry(ctx, shiftID, "cash_in", in)
}

// CashOut records a cash-out drawer entry on an open shift.
func (s *Service) CashOut(ctx context.Context, shiftID int64, in DrawerInput) (*DrawerEntry, error) {
	return s.drawerEntry(ctx, shiftID, "cash_out", in)
}

func (s *Service) drawerEntry(ctx context.Context, shiftID int64, typ string, in DrawerInput) (*DrawerEntry, error) {
	if in.Amount.LessThanOrEqual(decimal.Zero) {
		return nil, errors.New("amount must be > 0")
	}
	if !in.Amount.Equal(in.Amount.Round(2)) {
		return nil, errors.New("amount must have at most 2 decimal places")
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.GetShiftByIDForUpdate(ctx, tx, shiftID)
	if err != nil {
		return nil, err
	}
	if err := s.checkScope(ctx, int8ToPtr(row.StoreID)); err != nil {
		return nil, err
	}
	if err := requireCashier(ctx, row.CashierID); err != nil {
		return nil, err
	}
	actorID := auth.UserFrom(ctx).ID
	in.CreatedBy = &actorID
	if row.Status != "open" {
		return nil, ErrShiftNotOpen
	}
	entry, err := s.store.CreateDrawerEntry(ctx, tx, db.CreateDrawerEntryParams{
		ShiftID:   shiftID,
		Type:      typ,
		Amount:    in.Amount,
		Notes:     textOrNil(in.Notes),
		CreatedBy: int8OrNil(in.CreatedBy),
	})
	if err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	e := entryFromRow(entry)
	return &e, nil
}

// Close closes an open shift with the counted actual cash and stores the
// variance (actual - expected).
func (s *Service) Close(ctx context.Context, id int64, in CloseInput) (*Summary, error) {
	if in.ActualCash.IsNegative() {
		return nil, errors.New("actual_cash must be >= 0")
	}
	if !in.ActualCash.Equal(in.ActualCash.Round(2)) {
		return nil, errors.New("actual_cash must have at most 2 decimal places")
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.GetShiftByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if err := s.checkScope(ctx, int8ToPtr(row.StoreID)); err != nil {
		return nil, err
	}
	if err := requireCashier(ctx, row.CashierID); err != nil && !canOverride(ctx) {
		return nil, err
	}
	if row.Status != "open" {
		return nil, ErrShiftNotOpen
	}
	sums, err := s.store.SumDrawerByShift(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	cashSales, err := s.store.SumCashSalesByShift(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	var cashIn, cashOut decimal.Decimal
	for _, r := range sums {
		switch r.Type {
		case "cash_in":
			cashIn = r.Total
		case "cash_out":
			cashOut = r.Total
		}
	}
	expected := row.OpeningCash.Add(cashIn).Sub(cashOut).Add(cashSales)
	variance := in.ActualCash.Sub(expected)
	if !variance.IsZero() && !in.Override {
		return nil, ErrCashMismatch
	}
	if in.Override {
		if !canOverride(ctx) {
			return nil, ErrForbidden
		}
		if strings.TrimSpace(in.OverrideReason) == "" {
			return nil, errors.New("override_reason is required")
		}
	}
	u := auth.UserFrom(ctx)
	if u == nil {
		return nil, ErrForbidden
	}
	if err := s.store.RecordClosureAudit(ctx, tx, id, ClosureAudit{
		ExpectedCash: expected, ActualCash: in.ActualCash, Difference: variance,
		ActorID: u.ID, Reason: strings.TrimSpace(in.OverrideReason), Override: in.Override,
	}); err != nil {
		return nil, err
	}
	if err := s.store.CloseShift(ctx, tx, id, in.ActualCash, variance, in.Notes); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.Summary(ctx, id)
}

// List returns a paginated list of shifts.
func (s *Service) List(ctx context.Context, f Filter) ([]Shift, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	filter := db.CountCashierShiftsParams{
		CashierID: int8OrZero(f.CashierID),
		Status:    textOrNilStr(f.Status),
	}
	if sc := auth.StoreScopeFrom(ctx); !sc.PlatformWide() {
		filter.AllowedStoreIds = sc.IDs()
	}
	total, err := s.store.CountShifts(ctx, nil, filter)
	if err != nil {
		return nil, 0, err
	}
	rows, err := s.store.ListShifts(ctx, nil, db.ListCashierShiftsParams{
		Limit:     int32(f.PerPage),
		Offset:    int32((f.Page - 1) * f.PerPage),
		CashierID: filter.CashierID,
		Status:    filter.Status,
		// Tenant isolation must apply to the list itself, not just the count
		// (multi-tenant §5.4): restricted callers only ever see their stores'
		// shifts (plus platform rows are excluded by the SQL guard).
		AllowedStoreIds: filter.AllowedStoreIds,
	})
	if err != nil {
		return nil, 0, err
	}
	out := make([]Shift, 0, len(rows))
	// Drawer totals (cash_in/cash_out) exist only in cashier_drawer_entries;
	// fetch them in one batched query so the UI's Kas Masuk/Kas Keluar columns
	// stop rendering Rp 0 for every row.
	ids := make([]int64, 0, len(rows))
	for _, r := range rows {
		ids = append(ids, r.ID)
	}
	sums, err := s.store.SumDrawerByShifts(ctx, nil, ids)
	if err != nil {
		return nil, 0, err
	}
	drawer := make(map[int64]map[string]decimal.Decimal)
	for _, srow := range sums {
		if drawer[srow.ShiftID] == nil {
			drawer[srow.ShiftID] = make(map[string]decimal.Decimal)
		}
		drawer[srow.ShiftID][srow.Type] = srow.Total
	}
	for _, r := range rows {
		// The row used to embed CashierShift, so r.CashierShift was a field.
		// This repo's sqlc config flattens it into the row instead, so the
		// struct is assembled here. Same fields, same fromRow; only the shape
		// sqlc emits changed.
		sh := fromRow(db.CashierShift{
			ID:          r.ID,
			ShiftNumber: r.ShiftNumber,
			CashierID:   r.CashierID,
			Status:      r.Status,
			OpenedAt:    r.OpenedAt,
			OpeningCash: r.OpeningCash,
			ClosedAt:    r.ClosedAt,
			ActualCash:  r.ActualCash,
			Variance:    r.Variance,
			Notes:       r.Notes,
			RegisterID:  r.RegisterID,
			StoreID:     r.StoreID,
			CreatedAt:   r.CreatedAt,
			UpdatedAt:   r.UpdatedAt,
		})
		sh.CashierName = r.CashierName
		if m := drawer[r.ID]; m != nil {
			if v, ok := m["cash_in"]; ok {
				sh.CashIn = v
			}
			if v, ok := m["cash_out"]; ok {
				sh.CashOut = v
			}
		}
		out = append(out, sh)
	}
	return out, int(total), nil
}

// shiftCode allocates the monthly shift number: SHF-YYYYMM-NNNNNN.
func (s *Service) shiftCode(ctx context.Context, tx any) (string, error) {
	n, err := s.seq.Sequence(ctx, "shf", seq.MonthStart(time.Now().In(s.loc)), tx)
	if err != nil {
		return "", fmt.Errorf("shift number sequence: %w", err)
	}
	return fmt.Sprintf("SHF-%s-%06d", time.Now().In(s.loc).Format("200601"), n), nil
}

func fromRow(r db.CashierShift) Shift {
	sh := Shift{
		ID:          r.ID,
		ShiftNumber: r.ShiftNumber,
		CashierID:   r.CashierID,
		Status:      r.Status,
		OpenedAt:    r.OpenedAt.Time,
		OpeningCash: r.OpeningCash,
		Notes:       textToStrPtr(r.Notes),
		RegisterID:  int8ToPtr(r.RegisterID),
		StoreID:     int8ToPtr(r.StoreID),
		CreatedAt:   r.CreatedAt.Time,
		UpdatedAt:   r.UpdatedAt.Time,
	}
	if r.ClosedAt.Valid {
		t := r.ClosedAt.Time
		sh.ClosedAt = &t
	}
	if r.ActualCash.Valid {
		v := r.ActualCash.Decimal
		sh.ActualCash = &v
	}
	if r.Variance.Valid {
		v := r.Variance.Decimal
		sh.Variance = &v
	}
	return sh
}

func entryFromRow(r db.CashierDrawerEntry) DrawerEntry {
	return DrawerEntry{
		ID:        r.ID,
		ShiftID:   r.ShiftID,
		Type:      r.Type,
		Amount:    r.Amount,
		Notes:     textToStrPtr(r.Notes),
		CreatedBy: int8ToPtr(r.CreatedBy),
		CreatedAt: r.CreatedAt.Time,
	}
}

// --- pgtype helpers ----------------------------------------------------------

func int8OrNil(v *int64) pgtype.Int8 {
	if v == nil {
		return pgtype.Int8{}
	}
	return pgtype.Int8{Int64: *v, Valid: true}
}

func int8OrZero(v int64) pgtype.Int8 {
	if v <= 0 {
		return pgtype.Int8{}
	}
	return pgtype.Int8{Int64: v, Valid: true}
}

func int8ToPtr(v pgtype.Int8) *int64 {
	if !v.Valid {
		return nil
	}
	x := v.Int64
	return &x
}

func textOrNil(v *string) pgtype.Text {
	if v == nil {
		return pgtype.Text{}
	}
	return pgtype.Text{String: *v, Valid: true}
}

func textOrNilStr(v string) pgtype.Text {
	if v == "" {
		return pgtype.Text{}
	}
	return pgtype.Text{String: v, Valid: true}
}

func textToStrPtr(v pgtype.Text) *string {
	if !v.Valid {
		return nil
	}
	s := v.String
	return &s
}
