package notifications

import (
	"log"
	"strconv"
	"sync"

	"gorm.io/gorm"
)

// The one place that decides what a notification says and who gets it.
//
// How to use it from anywhere in the codebase:
//
//	notifications.Notify().WalletRecharged(userID, amount, balance)
//
// Three properties every call site relies on:
//
//   - **Never blocks.** Each event is dispatched on its own goroutine, so a
//     slow or unreachable FCM cannot hold up a wallet debit, a settlement run
//     or an HTTP response.
//   - **Never panics the caller.** The dispatcher recovers, because a push
//     notification must not be able to take down a billing path.
//   - **Never errors.** Nothing returns an error, on purpose: there is no
//     sensible way for a caller mid-transaction to handle "the push failed",
//     and pretending otherwise invites callers to abort real work over a
//     notification. Failures are logged.
//
// The in-app row in user_notifications is written even when the push itself
// fails, so a message is never lost just because a handset was off.

// Payload is one rendered notification.
type Payload struct {
	Title string
	Body  string

	// Data travels silently with the message for the app to route on. Values
	// must be strings — FCM rejects anything else.
	Data map[string]string

	// AndroidChannel picks the notification channel, which is what controls
	// sound and heads-up behaviour on Android 8+. The apps must create
	// channels with these ids.
	AndroidChannel string
}

// Android notification channels. `calls` is the loud one.
const (
	channelCalls    = "calls"
	channelWallet   = "wallet"
	channelGeneral  = "general"
	channelActivity = "activity"
)

// Notifier sends the notifications this product defines. One method per
// event, so a call site names what happened rather than composing a message.
type Notifier struct {
	repo *repository

	// Set when Firebase is not configured: the in-app rows are still written
	// and the push is skipped, rather than logging a failure per message.
	pushDisabled bool
}

var (
	instance *Notifier
	initOnce sync.Once

	// Tracks the goroutines dispatch has started. The server never waits on
	// them — it outlives them — but a short-lived process like the
	// notifytest command has to, or it exits before the sends complete.
	inFlight sync.WaitGroup
)

// Drain blocks until every dispatched notification has been attempted. For
// command-line tools and tests; the HTTP server has no reason to call it.
func Drain() {
	inFlight.Wait()
}

// Init wires the notifier to the database. Called from main once the
// connection is up.
func Init(db *gorm.DB) {

	initOnce.Do(func() {

		instance = &Notifier{
			repo: &repository{db: db},
		}

		if !Configured() {

			instance.pushDisabled = true

			_, err := loadCredentials()

			log.Printf(
				"notifications: push disabled (%v) — in-app notifications will still be recorded",
				err,
			)

			return
		}

		log.Println("notifications: firebase push enabled")
	})
}

// Notify returns the notifier. Safe before Init: an unconfigured notifier
// discards everything rather than panicking, so tests and any code path that
// runs without a database keep working.
func Notify() *Notifier {

	if instance == nil {
		return &Notifier{repo: &repository{}, pushDisabled: true}
	}

	return instance
}

//////////////////////////////////////////////////////////////
// Dispatch
//////////////////////////////////////////////////////////////

// dispatch records the notification and pushes it to every device the user has
// registered, on a goroutine.
//
// `apps` narrows delivery to the customer or astrologer app so the two
// audiences never receive each other's messages on a shared account.
func (n *Notifier) dispatch(
	userID uint,
	notificationType int,
	payload Payload,
	apps ...string,
) {

	if n == nil || userID == 0 {
		return
	}

	inFlight.Add(1)

	go func() {

		defer inFlight.Done()

		defer func() {

			// A notification is never worth a crash.
			if recovered := recover(); recovered != nil {
				log.Printf("notifications: recovered from %v", recovered)
			}
		}()

		n.repo.record(userID, payload.Title, payload.Body, notificationType)

		if n.pushDisabled {
			return
		}

		tokens := n.repo.deviceTokens(userID, apps...)

		for _, token := range tokens {

			result := send(token, payload)

			if result.Err == nil {
				continue
			}

			if result.Invalid {

				// The app is gone. Drop the registration so it is not
				// retried on every future notification.
				n.repo.pruneToken(token)

				continue
			}

			log.Printf("notifications: send to user %d failed: %v", userID, result.Err)
		}
	}()
}

// dispatchMany fans one payload out to a list of users.
func (n *Notifier) dispatchMany(
	userIDs []uint,
	notificationType int,
	payload Payload,
	apps ...string,
) {

	for _, userID := range userIDs {
		n.dispatch(userID, notificationType, payload, apps...)
	}
}

//////////////////////////////////////////////////////////////
// Customer notifications
//////////////////////////////////////////////////////////////

// WalletRecharged — the recharge has landed and the balance is updated.
// Sent after the wallet write commits, never before: a customer must not be
// told about money that a rolled-back transaction never credited.
func (n *Notifier) WalletRecharged(userID uint, amount float64, balance float64) {

	n.dispatch(userID, TypeWalletRecharged, Payload{
		Title: "Wallet recharged",
		Body: "₹" + money(amount) + " added to your wallet. Available balance ₹" +
			money(balance) + ".",
		AndroidChannel: channelWallet,
		Data: map[string]string{
			"type":    "WALLET_RECHARGED",
			"amount":  money(amount),
			"balance": money(balance),
			"screen":  "wallet",
		},
	}, AppCustomer)
}

// FavouriteAstrologerOnline — an astrologer a customer follows has come
// online. Fanned out to every follower.
func (n *Notifier) FavouriteAstrologerOnline(astrologerID uint) {

	if n == nil || astrologerID == 0 {
		return
	}

	followers := n.repo.followerUserIDs(astrologerID)

	if len(followers) == 0 {
		return
	}

	name := n.repo.astrologerName(astrologerID)

	n.dispatchMany(followers, TypeAstrologerOnline, Payload{
		Title:          name + " is online",
		Body:           "Your favourite astrologer is available now. Start a chat or call.",
		AndroidChannel: channelActivity,
		Data: map[string]string{
			"type":          "ASTROLOGER_ONLINE",
			"astrologer_id": strconv.FormatUint(uint64(astrologerID), 10),
			"screen":        "astrologer_profile",
		},
	}, AppCustomer)
}

// ConsultationAccepted — the astrologer took the chat / call / video request.
func (n *Notifier) ConsultationAccepted(
	userID uint,
	astrologerID uint,
	medium string,
	consultationID uint,
	consultationNo string,
) {

	if n == nil {
		return
	}

	name := n.repo.astrologerName(astrologerID)

	n.dispatch(userID, TypeRequestAccepted, Payload{
		Title: name + " accepted your " + mediumLabel(medium) + " request",
		Body:  "Your " + mediumLabel(medium) + " session is starting now.",
		AndroidChannel: channelCalls,
		Data: map[string]string{
			"type":            "CONSULTATION_ACCEPTED",
			"medium":          medium,
			"astrologer_id":   strconv.FormatUint(uint64(astrologerID), 10),
			"consultation_id": strconv.FormatUint(uint64(consultationID), 10),
			"consultation_no": consultationNo,
			"screen":          "consultation",
		},
	}, AppCustomer)
}

// AstrologerAvailableForWaiting — the astrologer a customer was queued behind
// has finished their last session. Sent to everyone still WAITING, because
// the queue is first come first served and all of them can now act.
func (n *Notifier) AstrologerAvailableForWaiting(astrologerID uint, waitingType string) {

	if n == nil || astrologerID == 0 {
		return
	}

	waiting := n.repo.waitingUserIDs(astrologerID, waitingType)

	if len(waiting) == 0 {
		return
	}

	name := n.repo.astrologerName(astrologerID)

	n.dispatchMany(waiting, TypeAstrologerFree, Payload{
		Title:          name + " is free now",
		Body:           "The astrologer you were waiting for has finished their last session. Connect now.",
		AndroidChannel: channelCalls,
		Data: map[string]string{
			"type":          "ASTROLOGER_AVAILABLE",
			"astrologer_id": strconv.FormatUint(uint64(astrologerID), 10),
			"waiting_type":  waitingType,
			"screen":        "astrologer_profile",
		},
	}, AppCustomer)
}

// QueueTurn — FIFO hand-over: the astrologer is free and it is this queued
// customer's turn. Only the customer at the head of the queue is told, and
// they have windowSeconds to tap Connect before the turn passes on.
func (n *Notifier) QueueTurn(
	userID uint,
	astrologerID uint,
	waitingType string,
	queueID uint,
	windowSeconds int,
) {

	if n == nil {
		return
	}

	name := n.repo.astrologerName(astrologerID)

	n.dispatch(userID, TypeAstrologerFree, Payload{
		Title:          name + " is free now",
		Body:           "Astrologer " + name + " is now available for " + queueMediumWord(waitingType) + ". Please try to reconnect.",
		AndroidChannel: channelCalls,
		Data: map[string]string{
			"type":           "QUEUE_TURN",
			"astrologer_id":  strconv.FormatUint(uint64(astrologerID), 10),
			"queue_id":       strconv.FormatUint(uint64(queueID), 10),
			"waiting_type":   waitingType,
			"window_seconds": strconv.Itoa(windowSeconds),
			"screen":         "waiting_queue",
		},
	}, AppCustomer)
}

// QueueExpired — the customer's queue request timed out, either waiting or
// after being notified without connecting.
func (n *Notifier) QueueExpired(
	userID uint,
	astrologerID uint,
	waitingType string,
	queueID uint,
) {

	if n == nil {
		return
	}

	name := n.repo.astrologerName(astrologerID)

	n.dispatch(userID, TypeQueueExpired, Payload{
		Title:          "Waiting request expired",
		Body:           "Your waiting request for Astrologer " + name + " has expired. Please try connecting again.",
		AndroidChannel: channelCalls,
		Data: map[string]string{
			"type":          "QUEUE_EXPIRED",
			"astrologer_id": strconv.FormatUint(uint64(astrologerID), 10),
			"queue_id":      strconv.FormatUint(uint64(queueID), 10),
			"waiting_type":  waitingType,
			"screen":        "astrologer_profile",
		},
	}, AppCustomer)
}

func queueMediumWord(waitingType string) string {

	if waitingType == "CHAT" || waitingType == "chat" {
		return "chat"
	}

	return "call"
}

// LowBalanceWarning — the running session will be cut in about 30 seconds
// unless the customer recharges. Fired once per session by the balance tick.
func (n *Notifier) LowBalanceWarning(
	userID uint,
	medium string,
	consultationID uint,
	remainingSeconds int,
) {

	n.dispatch(userID, TypeLowBalance, Payload{
		Title: "Low wallet balance",
		Body: "Your " + mediumLabel(medium) + " will end in about " +
			strconv.Itoa(remainingSeconds) + " seconds. Recharge now to continue.",
		AndroidChannel: channelCalls,
		Data: map[string]string{
			"type":              "INSUFFICIENT_FUND",
			"medium":            medium,
			"consultation_id":   strconv.FormatUint(uint64(consultationID), 10),
			"remaining_seconds": strconv.Itoa(remainingSeconds),
			"screen":            "wallet_recharge",
		},
	}, AppCustomer)
}

// KundaliGenerated — the report is ready to open.
func (n *Notifier) KundaliGenerated(userID uint, kundaliID uint, name string) {

	if name == "" {
		name = "Your kundali"
	}

	n.dispatch(userID, TypeKundaliReady, Payload{
		Title:          "Kundali ready",
		Body:           name + " has been generated. Tap to view your report.",
		AndroidChannel: channelGeneral,
		Data: map[string]string{
			"type":       "KUNDALI_GENERATED",
			"kundali_id": strconv.FormatUint(uint64(kundaliID), 10),
			"screen":     "kundali_detail",
		},
	}, AppCustomer)
}

// AdminMessageToCustomer — free-text message from the admin panel.
func (n *Notifier) AdminMessageToCustomer(userID uint, title string, body string) {

	n.dispatch(userID, TypeAdminMessage, Payload{
		Title:          title,
		Body:           body,
		AndroidChannel: channelGeneral,
		Data: map[string]string{
			"type":   "ADMIN_MESSAGE",
			"screen": "notifications",
		},
	}, AppCustomer)
}

// BroadcastToCustomers sends an admin message to every active customer.
// `limit` caps the fan-out; 0 means everybody.
func (n *Notifier) BroadcastToCustomers(title string, body string, limit int) int {

	if n == nil {
		return 0
	}

	recipients := n.repo.customerUserIDs(limit)

	n.dispatchMany(recipients, TypeAdminMessage, Payload{
		Title:          title,
		Body:           body,
		AndroidChannel: channelGeneral,
		Data: map[string]string{
			"type":   "ADMIN_MESSAGE",
			"screen": "notifications",
		},
	}, AppCustomer)

	return len(recipients)
}

//////////////////////////////////////////////////////////////
// Astrologer notifications
//////////////////////////////////////////////////////////////

// ProfileApproved — the admin has verified the astrologer's profile.
func (n *Notifier) ProfileApproved(astrologerID uint) {

	if n == nil {
		return
	}

	userID := n.repo.astrologerUserID(astrologerID)

	n.dispatch(userID, TypeProfileApproved, Payload{
		Title:          "Profile verified",
		Body:           "Your profile has been approved. You can go online and start taking consultations.",
		AndroidChannel: channelGeneral,
		Data: map[string]string{
			"type":   "PROFILE_APPROVED",
			"screen": "dashboard",
		},
	}, AppAstrologer)
}

// ProfileRejected is the other half of a verification decision. Not on the
// original list, but an astrologer left waiting with no word is worse than
// being told.
func (n *Notifier) ProfileRejected(astrologerID uint, reason string) {

	if n == nil {
		return
	}

	userID := n.repo.astrologerUserID(astrologerID)

	body := "Your profile could not be verified."

	if reason != "" {
		body = "Your profile could not be verified: " + reason
	}

	n.dispatch(userID, TypeProfileApproved, Payload{
		Title:          "Profile verification update",
		Body:           body,
		AndroidChannel: channelGeneral,
		Data: map[string]string{
			"type":   "PROFILE_REJECTED",
			"screen": "profile",
		},
	}, AppAstrologer)
}

// ConsultationRequestReceived — a customer is asking for a chat, call or
// video session. The loudest notification this API sends: the astrologer has
// seconds to respond before the customer gives up.
func (n *Notifier) ConsultationRequestReceived(
	astrologerID uint,
	customerUserID uint,
	medium string,
	consultationID uint,
	consultationNo string,
) {

	if n == nil {
		return
	}

	userID := n.repo.astrologerUserID(astrologerID)

	customer := n.repo.userName(customerUserID)

	n.dispatch(userID, TypeRequestReceived, Payload{
		Title: "New " + mediumLabel(medium) + " request",
		Body:  customer + " wants to start a " + mediumLabel(medium) + " with you.",
		AndroidChannel: channelCalls,
		Data: map[string]string{
			"type":            "CONSULTATION_REQUEST",
			"medium":          medium,
			"user_id":         strconv.FormatUint(uint64(customerUserID), 10),
			"consultation_id": strconv.FormatUint(uint64(consultationID), 10),
			"consultation_no": consultationNo,
			"screen":          "consultation_request",
		},
	}, AppAstrologer)
}

// CustomerWaiting — somebody has joined the call or chat queue.
func (n *Notifier) CustomerWaiting(
	astrologerID uint,
	customerUserID uint,
	waitingType string,
	position int,
) {

	if n == nil {
		return
	}

	userID := n.repo.astrologerUserID(astrologerID)

	customer := n.repo.userName(customerUserID)

	body := customer + " is waiting for a " + mediumLabel(waitingType) + " with you."

	if position > 0 {
		body += " Queue position " + strconv.Itoa(position) + "."
	}

	n.dispatch(userID, TypeCustomerWaiting, Payload{
		Title:          "Customer waiting",
		Body:           body,
		AndroidChannel: channelActivity,
		Data: map[string]string{
			"type":         "CUSTOMER_WAITING",
			"waiting_type": waitingType,
			"user_id":      strconv.FormatUint(uint64(customerUserID), 10),
			"position":     strconv.Itoa(position),
			"screen":       "waiting_list",
		},
	}, AppAstrologer)
}

// SettlementStatusChanged — an admin moved the astrologer's earnings through
// the review flow. This is the "pending to to_be_settled" half of the
// requirement; the credit itself is SettlementCredited.
func (n *Notifier) SettlementStatusChanged(
	astrologerID uint,
	toStatus string,
	consultationCount int,
	amount float64,
) {

	if n == nil {
		return
	}

	userID := n.repo.astrologerUserID(astrologerID)

	title, body := settlementStatusMessage(toStatus, consultationCount, amount)

	if title == "" {
		return
	}

	n.dispatch(userID, TypeSettlementUpdate, Payload{
		Title:          title,
		Body:           body,
		AndroidChannel: channelWallet,
		Data: map[string]string{
			"type":               "SETTLEMENT_STATUS",
			"settlement_status":  toStatus,
			"consultation_count": strconv.Itoa(consultationCount),
			"amount":             money(amount),
			"screen":             "settlement_history",
		},
	}, AppAstrologer)
}

// SettlementCredited — the settlement job has paid the batch into the
// astrologer's wallet. The money is withdrawable from this moment, which is
// why it says so.
func (n *Notifier) SettlementCredited(
	astrologerID uint,
	amount float64,
	walletBalance float64,
	consultationCount int,
	settlementNo string,
) {

	if n == nil {
		return
	}

	userID := n.repo.astrologerUserID(astrologerID)

	n.dispatch(userID, TypeSettlementUpdate, Payload{
		Title: "Settlement credited",
		Body: "₹" + money(amount) + " for " + strconv.Itoa(consultationCount) +
			" consultation(s) has been added to your wallet. Balance ₹" +
			money(walletBalance) + ". You can withdraw it now.",
		AndroidChannel: channelWallet,
		Data: map[string]string{
			"type":               "SETTLEMENT_CREDITED",
			"settlement_no":      settlementNo,
			"amount":             money(amount),
			"balance":            money(walletBalance),
			"consultation_count": strconv.Itoa(consultationCount),
			"screen":             "settlement_history",
		},
	}, AppAstrologer)
}

// WithdrawApproved — the payout has been approved and sent to the bank.
func (n *Notifier) WithdrawApproved(
	astrologerID uint,
	amount float64,
	reference string,
) {

	if n == nil {
		return
	}

	userID := n.repo.astrologerUserID(astrologerID)

	body := "₹" + money(amount) + " has been approved and transferred to your bank account."

	if reference != "" {
		body += " Reference " + reference + "."
	}

	n.dispatch(userID, TypeWithdrawApproved, Payload{
		Title:          "Withdrawal approved",
		Body:           body,
		AndroidChannel: channelWallet,
		Data: map[string]string{
			"type":      "WITHDRAW_APPROVED",
			"amount":    money(amount),
			"reference": reference,
			"screen":    "withdraw_history",
		},
	}, AppAstrologer)
}

// WithdrawRejected — the payout was refused and the amount is back in the
// wallet balance.
func (n *Notifier) WithdrawRejected(
	astrologerID uint,
	amount float64,
	reason string,
) {

	if n == nil {
		return
	}

	userID := n.repo.astrologerUserID(astrologerID)

	body := "Your withdrawal request of ₹" + money(amount) + " was rejected."

	if reason != "" {
		body += " Reason: " + reason
	}

	n.dispatch(userID, TypeWithdrawRejected, Payload{
		Title:          "Withdrawal rejected",
		Body:           body,
		AndroidChannel: channelWallet,
		Data: map[string]string{
			"type":   "WITHDRAW_REJECTED",
			"amount": money(amount),
			"reason": reason,
			"screen": "withdraw_history",
		},
	}, AppAstrologer)
}

// AdminMessageToAstrologer — free-text message from the admin panel.
func (n *Notifier) AdminMessageToAstrologer(astrologerID uint, title string, body string) {

	if n == nil {
		return
	}

	userID := n.repo.astrologerUserID(astrologerID)

	n.dispatch(userID, TypeAdminMessage, Payload{
		Title:          title,
		Body:           body,
		AndroidChannel: channelGeneral,
		Data: map[string]string{
			"type":   "ADMIN_MESSAGE",
			"screen": "notifications",
		},
	}, AppAstrologer)
}

// BroadcastToAstrologers sends an admin message to every active astrologer.
func (n *Notifier) BroadcastToAstrologers(title string, body string, limit int) int {

	if n == nil {
		return 0
	}

	recipients := n.repo.astrologerUserIDs(limit)

	n.dispatchMany(recipients, TypeAdminMessage, Payload{
		Title:          title,
		Body:           body,
		AndroidChannel: channelGeneral,
		Data: map[string]string{
			"type":   "ADMIN_MESSAGE",
			"screen": "notifications",
		},
	}, AppAstrologer)

	return len(recipients)
}

// TokensFor reports the device tokens a notification to this account would be
// delivered to. Diagnostics only — used by cmd/notifytest to answer "why did
// nothing arrive?" without guessing.
func (n *Notifier) TokensFor(userID uint, apps ...string) []string {

	if n == nil {
		return nil
	}

	return n.repo.deviceTokens(userID, apps...)
}

// AstrologerUserID exposes the astrologers.id -> users.id mapping for the
// same diagnostics.
func (n *Notifier) AstrologerUserID(astrologerID uint) uint {

	if n == nil {
		return 0
	}

	return n.repo.astrologerUserID(astrologerID)
}

// SendToUser is the escape hatch for a message this list does not cover, so a
// new notification does not have to start with a change to this package.
func (n *Notifier) SendToUser(userID uint, title string, body string, data map[string]string) {

	n.dispatch(userID, TypeGeneral, Payload{
		Title:          title,
		Body:           body,
		Data:           data,
		AndroidChannel: channelGeneral,
	})
}

//////////////////////////////////////////////////////////////
// Wording
//////////////////////////////////////////////////////////////

func settlementStatusMessage(
	toStatus string,
	count int,
	amount float64,
) (string, string) {

	switch toStatus {

	case "TO_BE_SETTLED":

		return "Earnings approved", "₹" + money(amount) + " from " + strconv.Itoa(count) +
			" consultation(s) has been approved and will be credited in the next settlement."

	case "SETTLED":

		return "Earnings settled", "₹" + money(amount) + " has been credited to your wallet."

	case "ON_HOLD":

		return "Earnings on hold", "₹" + money(amount) + " from " + strconv.Itoa(count) +
			" consultation(s) is on hold pending review. We will update you shortly."

	case "REJECTED":

		return "Earnings not approved", "₹" + money(amount) + " from " + strconv.Itoa(count) +
			" consultation(s) was not approved for settlement. Please contact support."

	case "PENDING":

		return "Earnings under review", "₹" + money(amount) + " from " + strconv.Itoa(count) +
			" consultation(s) has been returned for review."

	default:
		return "", ""
	}
}

func mediumLabel(medium string) string {

	switch medium {

	case "AUDIO", "CALL":
		return "call"

	case "VIDEO":
		return "video call"

	default:
		return "chat"
	}
}

// money renders an amount the way the apps display it — two decimals, no
// thousands separator, because it is concatenated into a sentence.
func money(amount float64) string {
	return strconv.FormatFloat(amount, 'f', 2, 64)
}
