package warehouses

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

	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/shopspring/decimal"
)

// Sentinel errors.
var (
	ErrNotFound = errors.New("not found")
)

// Seq allocates atomic counters (like catalog.Seq).
type Seq interface {
	Sequence(ctx context.Context, prefix string, date time.Time, tx any) (int64, error)
}

// Service implements warehouse use cases.
type Service struct {
	store Store
	seq   Seq
	loc   *time.Location
	// now is a clock seam. bucketDate deliberately does NOT read it - that is
	// the fix - but the field exists so a test can place two creates on
	// different days and so the regression test can actually fail. Without it,
	// reverting bucketDate to the current day would put both creates on the
	// same real date, no reset would occur, and the test would pass against the
	// broken code. Defaults to time.Now; production never sets it.
	now func() time.Time
}

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

func (s *Service) List(ctx context.Context, page, perPage int, search string) ([]db.Warehouse, int, error) {
	if page < 1 {
		page = 1
	}
	if perPage < 1 || perPage > 100 {
		perPage = 20
	}
	return s.store.List(ctx, page, perPage, search)
}

// Options lists active warehouses (id/code/name) for checkout pickers.
func (s *Service) Options(ctx context.Context) ([]Option, error) {
	return s.store.Options(ctx)
}

func (s *Service) Create(ctx context.Context, in Input) (*db.Warehouse, error) {
	if strings.TrimSpace(in.Name) == "" {
		return nil, errors.New("name is required")
	}
	if err := validateLatLng(in.Latitude, in.Longitude); err != nil {
		return nil, err
	}
	code, err := s.nextCode(ctx)
	if err != nil {
		return nil, err
	}
	w := &db.Warehouse{
		Code:     code,
		Name:     strings.TrimSpace(in.Name),
		Address:  pgtext(in.Address),
		IsActive: in.IsActive,
		Latitude: nullDecimal(in.Latitude), Longitude: nullDecimal(in.Longitude),
	}
	if err := s.store.Create(ctx, w); err != nil {
		return nil, err
	}
	return w, nil
}

func (s *Service) Update(ctx context.Context, id int64, in Input) (*db.Warehouse, error) {
	existing, err := s.store.ByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if strings.TrimSpace(in.Name) == "" {
		return nil, errors.New("name is required")
	}
	if err := validateLatLng(in.Latitude, in.Longitude); err != nil {
		return nil, err
	}
	existing.Name = strings.TrimSpace(in.Name)
	existing.Address = pgtext(in.Address)
	existing.IsActive = in.IsActive
	existing.Latitude = nullDecimal(in.Latitude)
	existing.Longitude = nullDecimal(in.Longitude)
	if err := s.store.Update(ctx, existing); err != nil {
		return nil, err
	}
	return s.store.ByID(ctx, id)
}

func (s *Service) Delete(ctx context.Context, id int64) error {
	return s.store.Delete(ctx, id)
}

// codeAnchorDate is the bucket date every GDG- code is allocated from.
//
// Same defect as EMP- and the same fix. The code carries no date component
// (GDG-001, GDG-002, ...) but the allocator's bucket key is (prefix, doc_date),
// so allocating from the current day reset the counter to 1 every midnight and
// the next morning's first warehouse was handed GDG-001 again. warehouses.code
// is UNIQUE (000001_init_schema.sql), so that is a unique violation on a value
// the caller never supplied.
//
// A fixed anchor gives the prefix one bucket for all time. This is the fix
// hr/service.go applies to EMP- (numberAnchorDate) and partners/service.go
// already applies to CUS- and SUP- (globalAnchorDate).
//
// It must match the anchor migration 000107 backfills.
var codeAnchorDate = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)

// nextCode allocates the next warehouse code from the shared anchor bucket
// rather than the current day. See codeAnchorDate for why, and migration 000107
// for the backfill that puts the counter above any existing GDG- rows.
func (s *Service) nextCode(ctx context.Context) (string, error) {
	n, err := s.seq.Sequence(ctx, "GDG", s.bucketDate(), nil)
	if err != nil {
		return "", fmt.Errorf("warehouse code sequence: %w", err)
	}
	return fmt.Sprintf("GDG-%03d", n), nil
}

// bucketDate is the (prefix, doc_date) bucket a GDG- code is allocated from:
// the fixed anchor, never the current day.
func (s *Service) bucketDate() time.Time {
	return codeAnchorDate
}

// validateLatLng enforces the same range as the stores.latitude/longitude and
// warehouses.latitude/longitude DB CHECK constraints (migration 000036).
func validateLatLng(lat, lng *decimal.Decimal) error {
	if lat != nil && (lat.LessThan(decimal.NewFromInt(-90)) || lat.GreaterThan(decimal.NewFromInt(90))) {
		return errors.New("latitude must be between -90 and 90")
	}
	if lng != nil && (lng.LessThan(decimal.NewFromInt(-180)) || lng.GreaterThan(decimal.NewFromInt(180))) {
		return errors.New("longitude must be between -180 and 180")
	}
	return nil
}

// nullDecimal maps an optional coordinate to a nullable numeric (nil → SQL NULL).
func nullDecimal(d *decimal.Decimal) decimal.NullDecimal {
	if d == nil {
		return decimal.NullDecimal{}
	}
	return decimal.NullDecimal{Decimal: *d, Valid: true}
}

func pgtext(s string) pgtype.Text {
	return pgtype.Text{String: s, Valid: true}
}
