package notifications

import (
	"os"
	"strings"
	"time"

	astrologermodel "astrology-api/models/astrologermodel"
	usermodel "astrology-api/models/usermodel"

	"gorm.io/gorm"
)

// Where a device token comes from, and where a notification is recorded.
//
// Two registries are in play and both are read, because the apps currently
// write to only one of them:
//
//	user_device_details.fcmToken  the proper registry — one row per app per
//	                              device, with appId telling the Customer App,
//	                              the Astrologer App and the Web App apart
//	users.device_token            a single token on the user row, which is
//	                              what login and /device-token actually write
//	                              today
//
// Reading both means notifications work now and keep working once the apps
// start registering per-device.

// App ids for user_device_details.appId.
//
// The column is a varchar but the data in it is numeric — the one row in the
// table holds "1" — so these are the numbers, not names. The mapping is not
// documented anywhere in this schema, so it is overridable by environment
// variable rather than hard-coded to a guess:
//
//	FCM_APP_ID_CUSTOMER    default 1
//	FCM_APP_ID_ASTROLOGER  default 2
//	FCM_APP_ID_WEB         default 3
//
// Getting the mapping wrong must not silence notifications, so deviceTokens
// falls back to every token the account has registered when the filter
// matches nothing. See the comment there.
var (
	AppCustomer   = appID("FCM_APP_ID_CUSTOMER", "1")
	AppAstrologer = appID("FCM_APP_ID_ASTROLOGER", "2")
	AppWeb        = appID("FCM_APP_ID_WEB", "3")
)

func appID(env string, fallback string) string {

	if value := strings.TrimSpace(os.Getenv(env)); value != "" {
		return value
	}

	return fallback
}

// notification_type codes written to user_notifications.notification_type, so
// the apps can route a tap to the right screen.
const (
	TypeGeneral = 0

	TypeWalletRecharged   = 1
	TypeAstrologerOnline  = 2
	TypeRequestAccepted   = 3
	TypeAstrologerFree    = 4
	TypeLowBalance        = 5
	TypeKundaliReady      = 6
	TypeAdminMessage      = 7

	TypeProfileApproved   = 11
	TypeRequestReceived   = 12
	TypeCustomerWaiting   = 13
	TypeSettlementUpdate  = 14
	TypeWithdrawApproved  = 15
	TypeWithdrawRejected  = 16

	// The consultation handshake, in its own block from 20.
	//
	// The customer range (1-7) and the astrologer range (11-16) are both
	// nearly full, and interleaving new codes between them would make the
	// next addition ambiguous about which app it belongs to.
	TypeRequestRejected   = 20
	TypeRequestMissed     = 21
	TypeRequestCancelled  = 22
	TypeConsultationEnded = 23
	TypeFreeChatEnding    = 24
	TypeSessionEndingSoon = 25
	TypeRequestNotJoined  = 26

	// The waiting queue. "Your turn" reuses TypeAstrologerFree (4).
	TypeQueueExpired = 27
)

type repository struct {
	db *gorm.DB
}

//------------------------------------------------
// Device tokens
//------------------------------------------------

// deviceTokens collects every push token for one user account, from both
// registries, de-duplicated.
//
// `apps` narrows the lookup to particular apps — an astrologer notification
// should not land on the same person's customer app. An empty list means any
// app. The users.device_token fallback cannot be filtered by app, since the
// column does not record one.
func (r *repository) deviceTokens(userID uint, apps ...string) []string {

	if userID == 0 || r.db == nil {
		return nil
	}

	seen := map[string]bool{}
	tokens := make([]string, 0, 4)

	add := func(token string) {

		token = strings.TrimSpace(token)

		if token == "" || seen[token] {
			return
		}

		seen[token] = true
		tokens = append(tokens, token)
	}

	//------------------------------------------------
	// Per-device registry
	//------------------------------------------------

	// Loads this account's registered devices, optionally narrowed to
	// particular apps.
	load := func(scoped []string) []usermodel.UserDeviceDetail {

		var devices []usermodel.UserDeviceDetail

		query := r.db.
			Where("userId = ?", userID).
			Where("fcmToken IS NOT NULL").
			Where("fcmToken <> ''").
			Where("COALESCE(isDelete, 0) = ?", false)

		if len(scoped) > 0 {
			query = query.Where("appId IN ?", scoped)
		}

		if err := query.Find(&devices).Error; err != nil {
			return nil
		}

		return devices
	}

	var devices []usermodel.UserDeviceDetail

	if len(apps) > 0 {

		// The web app is shared by both audiences, so it is always in scope
		// alongside whichever native app was asked for.
		scoped := append([]string{}, apps...)
		scoped = append(scoped, AppWeb)

		devices = load(scoped)

		// Nothing matched. Either this person genuinely has no device for
		// that app, or the appId mapping above does not match what the apps
		// write. Falling back to every registered device is the safer
		// failure: a notification on an extra device is a nuisance, a
		// notification that silently never arrives is a bug nobody notices.
		//
		// Safe here because a customer and an astrologer are separate users
		// rows in this schema, so "all of this account's devices" is already
		// one audience.
		if len(devices) == 0 {
			devices = load(nil)
		}

	} else {
		devices = load(nil)
	}

	for index := range devices {

		if devices[index].FcmToken != nil {
			add(*devices[index].FcmToken)
		}
	}

	//------------------------------------------------
	// Fallback on the user row
	//------------------------------------------------
	//
	// All three columns, not just device_token. The Go auth flows keep them
	// identical (repositories.SaveDeviceToken), but the PHP side may write
	// only fcm_token, and a token that exists under a name nobody reads is
	// the same as no token at all. add() de-duplicates, so the common case of
	// all three matching costs nothing.

	var user usermodel.User

	err := r.db.
		Select("id", "device_token", "fcm_token", "token").
		Where("id = ?", userID).
		First(&user).Error

	if err == nil {
		add(user.DeviceToken)
		add(user.FcmToken)
		add(user.Token)
	}

	return tokens
}

// pruneToken removes a token FCM has told us is dead, from wherever it is
// registered. Left alone on any other failure — a network blip must not
// unregister a live device.
func (r *repository) pruneToken(token string) {

	if r.db == nil || strings.TrimSpace(token) == "" {
		return
	}

	now := time.Now()

	r.db.
		Model(&usermodel.UserDeviceDetail{}).
		Where("fcmToken = ?", token).
		Updates(map[string]any{
			"isDelete":   true,
			"isActive":   false,
			"updated_at": now,
		})

	r.db.
		Model(&usermodel.User{}).
		Where("device_token = ?", token).
		Update("device_token", "")
}

//------------------------------------------------
// In-app notification history
//------------------------------------------------

// record writes the row the in-app notification list reads. It is written
// whether or not the push itself succeeds: a customer whose handset was off
// should still find the message in the app.
func (r *repository) record(
	userID uint,
	title string,
	body string,
	notificationType int,
) {

	if r.db == nil || userID == 0 {
		return
	}

	now := time.Now()

	notification := usermodel.UserNotification{
		UserID:           userID,
		Title:            truncate(title, 255),
		Description:      truncate(body, 255),
		NotificationType: notificationType,
		IsActive:         true,
		IsRead:           false,
		CreatedAt:        &now,
		UpdatedAt:        &now,
	}

	r.db.Create(&notification)
}

//------------------------------------------------
// Audience lookups
//------------------------------------------------

// astrologerUserID maps an astrologers.id to the users.id its device tokens
// are registered against. 0 when the astrologer has no linked account.
func (r *repository) astrologerUserID(astrologerID uint) uint {

	if r.db == nil || astrologerID == 0 {
		return 0
	}

	var astrologer astrologermodel.Astrologer

	err := r.db.
		Select("id", "userId").
		Where("id = ?", astrologerID).
		First(&astrologer).Error

	if err != nil {
		return 0
	}

	return astrologer.UserID
}

// astrologerName is the public-facing name to put in a customer's
// notification, falling back to the legal name for profiles completed before
// displayName existed.
func (r *repository) astrologerName(astrologerID uint) string {

	if r.db == nil || astrologerID == 0 {
		return "Your astrologer"
	}

	var astrologer astrologermodel.Astrologer

	err := r.db.
		Select("id", "name", "displayName").
		Where("id = ?", astrologerID).
		First(&astrologer).Error

	if err != nil {
		return "Your astrologer"
	}

	if name := strings.TrimSpace(astrologer.DisplayName); name != "" {
		return name
	}

	if name := strings.TrimSpace(astrologer.Name); name != "" {
		return name
	}

	return "Your astrologer"
}

func (r *repository) userName(userID uint) string {

	if r.db == nil || userID == 0 {
		return "A customer"
	}

	var user usermodel.User

	err := r.db.
		Select("id", "name").
		Where("id = ?", userID).
		First(&user).Error

	if err != nil || strings.TrimSpace(user.Name) == "" {
		return "A customer"
	}

	return user.Name
}

// followerUserIDs lists the customers who favourited an astrologer and are
// therefore told when that astrologer comes online.
func (r *repository) followerUserIDs(astrologerID uint) []uint {

	if r.db == nil || astrologerID == 0 {
		return nil
	}

	var ids []uint

	err := r.db.
		Model(&usermodel.UserFollowing{}).
		Where("astrologerId = ?", astrologerID).
		Where("isActive = ?", true).
		Where("isDelete = ?", false).
		Group("userId").
		Pluck("userId", &ids).Error

	if err != nil {
		return nil
	}

	return ids
}

// waitingUserIDs lists the customers queued for an astrologer who are still
// WAITING — the people to tell when that astrologer frees up.
//
// waitingType is CHAT or CALL; empty means both.
func (r *repository) waitingUserIDs(astrologerID uint, waitingType string) []uint {

	if r.db == nil || astrologerID == 0 {
		return nil
	}

	var ids []uint

	query := r.db.
		Model(&astrologermodel.AstrologerWaitingQueue{}).
		Where("astrologer_id = ?", astrologerID).
		Where("status = ?", "WAITING")

	if value := strings.TrimSpace(strings.ToUpper(waitingType)); value != "" {
		query = query.Where("waiting_type = ?", value)
	}

	if err := query.Group("user_id").Pluck("user_id", &ids).Error; err != nil {
		return nil
	}

	return ids
}

// customerUserIDs lists every active customer, for an admin broadcast.
// Astrologers are excluded by role so a broadcast to customers does not land
// on the astrologer app.
func (r *repository) customerUserIDs(limit int) []uint {

	if r.db == nil {
		return nil
	}

	var ids []uint

	query := r.db.
		Table("users AS u").
		Where("u.isActive = ?", true).
		Where("u.isDelete = ?", false).
		Where("NOT EXISTS (SELECT 1 FROM astrologers a WHERE a.userId = u.id AND a.isDelete = 0)")

	if limit > 0 {
		query = query.Limit(limit)
	}

	if err := query.Pluck("u.id", &ids).Error; err != nil {
		return nil
	}

	return ids
}

// astrologerUserIDs lists every active astrologer's login, for an admin
// broadcast.
func (r *repository) astrologerUserIDs(limit int) []uint {

	if r.db == nil {
		return nil
	}

	var ids []uint

	query := r.db.
		Model(&astrologermodel.Astrologer{}).
		Where("isDelete = ?", false).
		Where("userId > ?", 0)

	if limit > 0 {
		query = query.Limit(limit)
	}

	if err := query.Group("userId").Pluck("userId", &ids).Error; err != nil {
		return nil
	}

	return ids
}

func truncate(value string, max int) string {

	value = strings.TrimSpace(value)

	if len(value) <= max {
		return value
	}

	return value[:max]
}
