package services

import (
	"astrology-api/constants"
	dto "astrology-api/dto/consultation"
	models "astrology-api/models/usermodel"
	"astrology-api/notifications"
	"errors"
	"fmt"
	"math"
	"strings"
	"time"

	"gorm.io/gorm"
)

// Shared machinery behind the handshake: the configuration reads, the
// unbilled-close write, the lazy expiry that keeps the cron a backstop rather
// than the mechanism, and the sweep itself.

const stampLayout = "2006-01-02 15:04:05"

//////////////////////////////////////////////////////////////
// Configuration
//////////////////////////////////////////////////////////////

func (s *consultationService) ringTimeoutSeconds() int {

	return int(s.repository.GetSystemFlagFloat(
		constants.FlagConsultationRingTimeoutSecs,
		defaultRingTimeoutSeconds,
	))
}

func (s *consultationService) joinTimeoutSeconds() int {

	return int(s.repository.GetSystemFlagFloat(
		constants.FlagConsultationJoinTimeoutSecs,
		defaultJoinTimeoutSeconds,
	))
}

func (s *consultationService) warningSeconds() int {

	return int(s.repository.GetSystemFlagFloat(
		constants.FlagConsultationWarningSeconds,
		lowBalanceWarningSeconds,
	))
}

// tickTimeoutSeconds is how long a session may go quiet before the sweeper
// closes and bills it.
//
// Chat gets its own, longer value: backgrounding an app in the middle of a
// chat is ordinary behaviour, and closing a customer's session because they
// checked a message for two minutes would be indefensible. Mid-call the same
// silence really does mean the app is gone.
func (s *consultationService) tickTimeoutSeconds(medium string) int {

	if medium == constants.MediumChat {

		chat := int(s.repository.GetSystemFlagFloat(
			constants.FlagChatTickTimeoutSecs,
			defaultChatTickTimeoutSeconds,
		))

		if chat > 0 {
			return chat
		}
	}

	return int(s.repository.GetSystemFlagFloat(
		constants.FlagConsultationTickTimeoutSecs,
		defaultTickTimeoutSeconds,
	))
}

// affordableSeconds is the whole seconds of talk time a balance can pay for.
func (s *consultationService) affordableSeconds(balance float64, rate float64) int {

	if rate <= 0 || balance <= 0 {
		return 0
	}

	return s.cappedSeconds(int(math.Floor((balance / rate) * 60)))
}

// cappedSeconds applies the platform-wide ceiling on a single session,
// whatever the wallet allows. 0 disables it.
func (s *consultationService) cappedSeconds(seconds int) int {

	maxMinutes := s.repository.GetSystemFlagFloat(
		constants.FlagConsultationMaxMinutes,
		defaultConsultationMaxMinutes,
	)

	if maxMinutes <= 0 {
		return seconds
	}

	ceiling := int(maxMinutes * 60)

	if seconds > ceiling {
		return ceiling
	}

	return seconds
}

func (s *consultationService) lockedBalance(tx *gorm.DB, userID uint) (float64, error) {

	wallet, err := s.repository.GetWalletForUpdate(tx, userID)

	if err != nil {
		return 0, err
	}

	if wallet == nil || wallet.Amount == nil {
		return 0, nil
	}

	return *wallet.Amount, nil
}

//////////////////////////////////////////////////////////////
// Deadlines
//////////////////////////////////////////////////////////////

func (s *consultationService) ringDeadline(consultation *models.Consultation) time.Time {

	from := consultation.CreatedAt

	if consultation.RequestedAt != nil {
		from = consultation.RequestedAt
	}

	if from == nil {
		return time.Now()
	}

	return from.Add(time.Duration(s.ringTimeoutSeconds()) * time.Second)
}

func (s *consultationService) joinDeadline(consultation *models.Consultation) time.Time {

	from := consultation.CreatedAt

	if consultation.AcceptedAt != nil {
		from = consultation.AcceptedAt
	}

	if from == nil {
		return time.Now()
	}

	return from.Add(time.Duration(s.joinTimeoutSeconds()) * time.Second)
}

func secondsUntil(deadline time.Time, now time.Time) int {

	seconds := int(deadline.Sub(now).Seconds())

	if seconds < 0 {
		return 0
	}

	return seconds
}

func remainingFor(consultation *models.Consultation, elapsed int) int {

	remaining := consultation.MaxBillableSeconds - elapsed

	if remaining < 0 {
		return 0
	}

	return remaining
}

//////////////////////////////////////////////////////////////
// Closing without billing
//////////////////////////////////////////////////////////////

// closeUnbilled ends a session that never ran: rejected, cancelled, unanswered
// or never opened.
//
// None of these are billed sessions, so none of them touch the wallet, write a
// wallettransaction row, or mirror into the chat history — a request an
// astrologer declined has no business appearing in the customer's chat
// history. It also clears freeMinutes, which hands the free chat back and
// releases the claim the unique index holds.
func (s *consultationService) closeUnbilled(
	tx *gorm.DB,
	consultation *models.Consultation,
	status string,
	reason string,
	now time.Time,
) error {

	consultation.Status = status
	consultation.EndReason = reason
	consultation.EndedAt = &now
	consultation.DurationSeconds = 0
	consultation.BilledSeconds = 0
	consultation.BilledMinutes = 0
	consultation.GrossAmount = 0
	consultation.PlatformFeePercent = 0
	consultation.PlatformFeeAmount = 0
	consultation.AstrologerEarning = 0
	consultation.SettlementStatus = constants.SettlementStatusNA
	consultation.FreeMinutes = 0
	consultation.FreeSeconds = 0
	consultation.UpdatedAt = &now

	if err := s.repository.UpdateConsultation(tx, consultation); err != nil {
		return err
	}

	// An accepted session marked the astrologer busy. A request that never
	// got that far did not, and clearing a flag that was never set is a
	// no-op, so this is safe on every path.
	if err := s.repository.SetAstrologerBusy(tx, consultation.AstrologerID, false); err != nil {
		return err
	}

	// A request placed from the waiting queue: its row follows it.
	s.syncQueue(tx, consultation)

	return nil
}

// expireIfLapsed closes a session that has run past the ring or join deadline,
// and reports whether it did.
//
// This is what keeps the cron a backstop rather than the mechanism. An
// external scheduler cannot fire more than once a minute, so a 60-second ring
// enforced by cron alone would leave a customer staring at a spinner for two
// minutes. Every read path calls this instead, exactly as StartConsultation
// has always closed a stale session on sight.
func (s *consultationService) expireIfLapsed(consultation *models.Consultation) bool {

	now := time.Now()

	var status, reason string

	switch consultation.Status {

	case constants.ConsultationStatusRequested:

		if s.ringDeadline(consultation).After(now) {
			return false
		}

		status, reason = constants.ConsultationStatusMissed, constants.EndReasonNoAnswer

	case constants.ConsultationStatusAccepted:

		// Only when the customer never opened it. An accepted session that
		// started is a live session and belongs to the stale-tick sweep.
		if consultation.StartedAt != nil || s.joinDeadline(consultation).After(now) {
			return false
		}

		status, reason = constants.ConsultationStatusCancelled, constants.EndReasonNotJoined

	default:
		return false
	}

	if err := s.closeLapsed(consultation, status, reason, now); err != nil {
		return false
	}

	// The astrologer is free again: next customer in the queue.
	_, _ = s.advanceQueue(consultation.AstrologerID)

	if status == constants.ConsultationStatusMissed {

		notifications.Notify().ConsultationMissed(
			consultation.UserID,
			consultation.AstrologerID,
			consultation.Medium,
			consultation.ID,
		)

	} else {

		notifications.Notify().ConsultationNotJoined(
			consultation.UserID,
			consultation.AstrologerID,
			consultation.Medium,
			consultation.ID,
		)
	}

	return true
}

func (s *consultationService) closeLapsed(
	consultation *models.Consultation,
	status string,
	reason string,
	now time.Time,
) error {

	tx := s.repository.Begin()

	if tx.Error != nil {
		return tx.Error
	}

	committed := false

	defer func() {
		if !committed {
			tx.Rollback()
		}
	}()

	locked, err := s.repository.GetConsultationForUpdate(tx, consultation.ID)

	if err != nil {
		return err
	}

	if locked == nil {
		return errors.New("consultation not found")
	}

	// Somebody else got there first — an accept, a cancel, a concurrent
	// sweep. Their write stands.
	if locked.Status != consultation.Status {
		return errors.New("consultation already moved on")
	}

	if err := s.closeUnbilled(tx, locked, status, reason, now); err != nil {
		return err
	}

	if err := tx.Commit().Error; err != nil {
		return err
	}

	committed = true

	*consultation = *locked

	return nil
}

//////////////////////////////////////////////////////////////
// Free chat
//////////////////////////////////////////////////////////////

// freeChatEntitlement reports how many free minutes this customer is entitled
// to for this medium right now, or 0.
//
// Once per customer, for life, and chat only — audio and video are always
// paid. The answer is derived from the sessions themselves rather than from a
// flag on the user, so there is no second source of truth to drift and support
// can always see which session consumed it.
func (s *consultationService) freeChatEntitlement(userID uint, medium string) (int, error) {

	if medium != constants.MediumChat {
		return 0, nil
	}

	if s.repository.GetSystemFlagFloat(constants.FlagFreeChatEnabled, 1) == 0 {
		return 0, nil
	}

	minutes := int(s.repository.GetSystemFlagFloat(
		constants.FlagFreeChatMinutes,
		defaultFreeChatMinutes,
	))

	if minutes <= 0 {
		return 0, nil
	}

	used, err := s.repository.HasUsedFreeChat(userID, s.freeChatMinConnectedSeconds())

	if err != nil {
		return 0, err
	}

	if used {
		return 0, nil
	}

	return minutes, nil
}

// freeChatEntitlementLocked re-asks the question inside the accept
// transaction, ignoring the session being accepted.
//
// Belt and braces: the front door already refuses a second request while one
// is open, and a customer cannot have two live sessions. This covers the
// historical case, and the unique index on the generated claim column covers
// the rest.
func (s *consultationService) freeChatEntitlementLocked(
	tx *gorm.DB,
	userID uint,
	exceptID uint,
) (int, error) {

	minutes, err := s.freeChatEntitlement(userID, constants.MediumChat)

	if err != nil || minutes == 0 {
		return 0, err
	}

	var claimed int64

	err = tx.
		Model(&models.Consultation{}).
		Where("userId = ?", userID).
		Where("id <> ?", exceptID).
		Where("freeMinutes > ?", 0).
		Where("isDelete = ?", false).
		Count(&claimed).Error

	if err != nil {
		return 0, err
	}

	if claimed > 0 {
		return 0, nil
	}

	return minutes, nil
}

func (s *consultationService) freeChatMinConnectedSeconds() int {

	return int(s.repository.GetSystemFlagFloat(
		constants.FlagFreeChatMinConnectedSeconds,
		defaultFreeChatMinConnectedSeconds,
	))
}

// wouldBeFree answers, for a still-ringing chat request, whether accepting it
// would make it free. The astrologer sees this before deciding.
func (s *consultationService) wouldBeFree(consultation models.Consultation) bool {

	if consultation.Medium != constants.MediumChat {
		return false
	}

	minutes, err := s.freeChatEntitlement(consultation.UserID, consultation.Medium)

	return err == nil && minutes > 0
}

func (s *consultationService) displayFreeMinutes(consultation models.Consultation) int {

	if consultation.FreeMinutes > 0 {
		return consultation.FreeMinutes
	}

	minutes, err := s.freeChatEntitlement(consultation.UserID, consultation.Medium)

	if err != nil {
		return 0
	}

	return minutes
}

//////////////////////////////////////////////////////////////
// Presentation helpers
//////////////////////////////////////////////////////////////

// estimatedEarning is what the astrologer takes home if the session runs its
// full cap, after commission.
//
// A free chat is not zero here: the platform funds it, so the astrologer is
// paid either way, and an app that showed nothing would make accepting a free
// chat look like working for free.
func (s *consultationService) estimatedEarning(consultation *models.Consultation) float64 {

	gross := chargeFor(consultation.MaxBillableSeconds, consultation.RatePerMinute)

	feePercent := s.repository.GetSystemFlagFloat(
		constants.FlagPlatformCommissionPercent,
		defaultPlatformCommissionPercent,
	)

	if feePercent < 0 {
		feePercent = 0
	}

	if feePercent > 100 {
		feePercent = 100
	}

	// The platform pays the free minutes at the full listed rate rather than
	// taking a cut of its own subsidy.
	if consultation.FreeMinutes > 0 {
		return gross
	}

	return round2(gross - round2(gross*feePercent/100))
}

// currentCharge is what the session would cost if it ended this second. Always
// 0 while the free allowance still covers the elapsed time.
func (s *consultationService) currentCharge(
	consultation *models.Consultation,
	elapsed int,
) float64 {

	paid := elapsed - consultation.FreeSeconds

	if paid <= 0 {
		return 0
	}

	return chargeFor(paid, consultation.RatePerMinute)
}

func (s *consultationService) customerIdentity(userID uint) (string, string) {

	user, err := s.repository.GetUserByID(userID)

	if err != nil || user == nil {
		return "", ""
	}

	return strings.TrimSpace(user.Name), ""
}

func formatStamp(value *time.Time) string {

	if value == nil {
		return ""
	}

	return value.Format(stampLayout)
}

func formatDate(value *time.Time) string {

	if value == nil {
		return ""
	}

	return value.Format("2006-01-02")
}

func truncate(value string, limit int) string {

	runes := []rune(value)

	if len(runes) <= limit {
		return value
	}

	return string(runes[:limit])
}

// isDuplicateKey recognises the unique-violation the free-chat claim index
// raises when two accepts race for the same customer's one free chat.
func isDuplicateKey(err error) bool {

	if err == nil {
		return false
	}

	if errors.Is(err, gorm.ErrDuplicatedKey) {
		return true
	}

	message := strings.ToLower(err.Error())

	return strings.Contains(message, "duplicate entry") ||
		strings.Contains(message, "error 1062")
}

//////////////////////////////////////////////////////////////
// The sweep
//////////////////////////////////////////////////////////////

// Sweep is everything the admin panel's cron drives, in one idempotent call.
//
// Order matters. Warnings go out before anything is cut, and a session that
// hit its cap is closed with the honest reason before the stale-tick pass
// could label the same row ABANDONED.
//
// Every pass is safe to run twice at once: bill() re-reads the row FOR UPDATE
// and returns the existing receipt for an already-closed session, and the
// unbilled closes re-check the status under the same lock. A pass that fails
// is recorded and the rest still run — one broken session must never stop the
// sweep.
func (s *consultationService) Sweep(limit int) (*dto.SweepResult, error) {

	if limit <= 0 {
		limit = 100
	}

	result := &dto.SweepResult{
		Limit: limit,
		RunAt: time.Now().Format(stampLayout),
	}

	warned, err := s.sweepWarnings(limit)
	result.Warned = warned
	record(result, "warnings", err)

	capped, err := s.sweepOverrun(limit)
	result.Capped = capped
	record(result, "cap", err)

	abandoned, err := s.SweepStaleConsultations(limit)
	result.Abandoned = abandoned
	record(result, "stale", err)

	missed, err := s.sweepExpiredRequests(limit)
	result.Missed = missed
	record(result, "requests", err)

	unjoined, err := s.sweepUnjoined(limit)
	result.Unjoined = unjoined
	record(result, "unjoined", err)

	reconciled, err := s.repository.ReconcileAstrologerBusy()
	result.BusyReconciled = int(reconciled)
	record(result, "busy", err)

	// Last, so sessions closed above have already freed their astrologers
	// for the next customer in the queue.
	queue, err := s.sweepQueue(limit)
	result.Queue = queue
	record(result, "queue", err)

	return result, nil
}

func record(result *dto.SweepResult, pass string, err error) {

	if err == nil {
		return
	}

	result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", pass, err))
}

// sweepWarnings is the safety net behind the "your session is ending" push.
//
// The customer's tick is the fast path, but a backgrounded app stops ticking
// and would then never be warned at all — and the astrologer app does not tick
// in the first place. The claim is a conditional UPDATE, so the tick and this
// pass running at the same time still send exactly one warning each way.
func (s *consultationService) sweepWarnings(limit int) (int, error) {

	warnSeconds := s.warningSeconds()

	records, err := s.repository.GetPendingWarnings(time.Now(), warnSeconds, limit)

	if err != nil {
		return 0, err
	}

	sent := 0

	for index := range records {

		consultation := records[index]

		claimed, err := s.repository.ClaimWarning(consultation.ID, time.Now())

		if err != nil || !claimed {
			continue
		}

		s.notifyEndingSoon(&consultation, remainingFor(&consultation, elapsedSeconds(&consultation, time.Now())))

		sent++
	}

	return sent, nil
}

func (s *consultationService) sweepOverrun(limit int) (int, error) {

	records, err := s.repository.GetOverrunConsultations(time.Now(), limit)

	if err != nil {
		return 0, err
	}

	closed := 0

	for index := range records {

		consultation := records[index]

		reason := constants.EndReasonInsufficientBalance

		if consultation.FreeMinutes > 0 && consultation.FreeSeconds >= consultation.MaxBillableSeconds {
			reason = constants.EndReasonFreeMinutesOver
		}

		if _, err := s.bill(&consultation, reason, 0); err != nil {
			continue
		}

		s.notifyEnded(&consultation)

		closed++
	}

	return closed, nil
}

func (s *consultationService) sweepExpiredRequests(limit int) (int, error) {

	before := time.Now().Add(-time.Duration(s.ringTimeoutSeconds()) * time.Second)

	records, err := s.repository.GetExpiredRequested(before, limit)

	if err != nil {
		return 0, err
	}

	closed := 0

	for index := range records {

		consultation := records[index]

		if s.expireIfLapsed(&consultation) {
			closed++
		}
	}

	return closed, nil
}

func (s *consultationService) sweepUnjoined(limit int) (int, error) {

	before := time.Now().Add(-time.Duration(s.joinTimeoutSeconds()) * time.Second)

	records, err := s.repository.GetUnjoinedAccepted(before, limit)

	if err != nil {
		return 0, err
	}

	closed := 0

	for index := range records {

		consultation := records[index]

		if s.expireIfLapsed(&consultation) {
			closed++
		}
	}

	return closed, nil
}

//////////////////////////////////////////////////////////////
// Notifications
//////////////////////////////////////////////////////////////

// notifyEndingSoon warns both parties at once.
//
// The wording branches on who is paying: "your free minutes are ending, carry
// on at the astrologer's rate?" is a different message from "your wallet is
// about to run out", and sending the wrong one is the kind of thing that ends
// in a refund request.
func (s *consultationService) notifyEndingSoon(
	consultation *models.Consultation,
	remaining int,
) {

	isFree := consultation.FreeMinutes > 0

	if isFree {

		notifications.Notify().FreeChatEnding(
			consultation.UserID,
			consultation.ID,
			remaining,
			consultation.RatePerMinute,
		)

	} else {

		notifications.Notify().LowBalanceWarning(
			consultation.UserID,
			consultation.Medium,
			consultation.ID,
			remaining,
		)
	}

	notifications.Notify().SessionEndingSoonAstrologer(
		consultation.AstrologerID,
		consultation.Medium,
		consultation.ID,
		remaining,
		isFree,
	)
}

func (s *consultationService) notifyEnded(consultation *models.Consultation) {

	notifications.Notify().ConsultationEnded(
		consultation.UserID,
		consultation.AstrologerID,
		consultation.Medium,
		consultation.ID,
		consultation.ConsultationNo,
		consultation.EndReason,
		consultation.DurationSeconds,
		consultation.GrossAmount,
		consultation.AstrologerEarning,
	)
}
