package services

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

// The request -> accept -> start handshake.
//
// Three steps, and the money only starts at the third:
//
//	request     the customer asks. Nothing is billed, no channel is joined,
//	            no token is minted. The astrologer's app rings.
//
//	accept      the astrologer commits. They are marked busy and get their
//	            own credentials, but the clock does not run: the customer may
//	            still be reading the notification, and an astrologer who
//	            accepts and waits forty seconds must not burn forty seconds
//	            of that customer's wallet — or a third of their free chat.
//
//	start-chat  the customer confirms. startedAt is written, the cap is
//	            recomputed against the live wallet, and billing begins.
//
// Every transition takes the row FOR UPDATE first. Accept, reject, cancel and
// the ring timeout are a four-way race, and the lock plus a status re-check is
// the only thing that makes exactly one of them win.

//////////////////////////////////////////////////////////////
// Precheck
//////////////////////////////////////////////////////////////

func (s *consultationService) Precheck(
	userID uint,
	astrologerID uint,
	medium string,
) (*dto.PrecheckResponse, error) {

	medium = strings.ToUpper(strings.TrimSpace(medium))

	if !constants.IsValidMedium(medium) {
		return nil, errors.New("medium must be CHAT, AUDIO or VIDEO")
	}

	astrologer, err := s.repository.GetAstrologerByID(astrologerID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	rate := rateForMedium(astrologer, medium)

	balance, err := s.walletBalance(userID)

	if err != nil {
		return nil, err
	}

	freeMinutes, err := s.freeChatEntitlement(userID, medium)

	if err != nil {
		return nil, err
	}

	response := &dto.PrecheckResponse{
		AstrologerID:       astrologer.ID,
		AstrologerName:     astrologerName(astrologer),
		Medium:             medium,
		IsFreeChat:         freeMinutes > 0,
		FreeMinutes:        freeMinutes,
		RatePerMinute:      rate,
		WalletBalance:      balance,
		RingTimeoutSeconds: s.ringTimeoutSeconds(),
		CanStart:           true,
	}

	if freeMinutes > 0 {

		response.MaxBillableSeconds = freeMinutes * 60

	} else {

		required := rate * s.repository.GetSystemFlagFloat(
			constants.FlagMinConsultationMinutes,
			defaultMinConsultationMinutes,
		)

		response.RequiredBalance = round2(required)

		if balance < required {
			response.Shortfall = round2(required - balance)
		}

		response.MaxBillableSeconds = s.affordableSeconds(balance, rate)
	}

	response.MaxBillableMinutes = response.MaxBillableSeconds / 60

	//------------------------------------------------
	// Why It Cannot Start
	//------------------------------------------------

	if open, err := s.repository.GetOpenConsultation(userID); err == nil && open != nil {

		// A session left open by a dead app should not block the customer
		// forever, so the same lazy close the rest of this service uses
		// applies here too.
		if !s.expireIfLapsed(open) {

			response.HasOpenConsultation = true
			response.OpenConsultationID = open.ID

			response.CanStart = false
			response.BlockedReason = "SESSION_ALREADY_OPEN"
			response.BlockedMessage = fmt.Sprintf(
				"a %s session is already open, finish it before starting another",
				strings.ToLower(open.Medium),
			)

			return response, nil
		}
	}

	if reason, message := s.blockingReason(astrologer, medium, rate, balance, freeMinutes); reason != "" {

		response.CanStart = false
		response.BlockedReason = reason
		response.BlockedMessage = message

		return response, nil
	}

	// Free, but held for the next customer in the waiting queue.
	if _, reason, message := s.queueGate(userID, astrologer); reason != "" {

		response.CanStart = false
		response.BlockedReason = reason
		response.BlockedMessage = message
	}

	return response, nil
}

// blockingReason runs every gate in the order the customer would care about.
// Empty reason means the session can be requested.
func (s *consultationService) blockingReason(
	astrologer *models.Astrologer,
	medium string,
	rate float64,
	balance float64,
	freeMinutes int,
) (string, string) {

	if medium == constants.MediumChat &&
		s.repository.GetSystemFlagFloat(constants.FlagChatConsultationEnabled, 1) == 0 {

		return "MEDIUM_DISABLED", "chat consultations are temporarily unavailable"
	}

	if !astrologer.IsActive {
		return "ASTROLOGER_OFFLINE", "astrologer is not available"
	}

	if rate <= 0 {
		return "NO_RATE_CONFIGURED", fmt.Sprintf(
			"astrologer has no %s rate configured",
			strings.ToLower(medium),
		)
	}

	// Refused rather than started without credentials: otherwise the request,
	// the accept and the customer's confirmation would all succeed, the clock
	// would start, and the customer would be billed for a session neither
	// party could ever join.
	//
	// The message names the missing variable. "Not configured" on its own sends
	// whoever is holding the phone looking for a bug in the astrologer's
	// availability instead of at a blank line in .env.
	if s.agora == nil || !s.agora.Enabled() {

		message := "consultation service is not configured"

		if problem := config.Agora.Problem(); problem != "" {
			message = "consultation service is not configured: " + problem
		}

		return "SERVICE_NOT_CONFIGURED", message
	}

	if reason, message := s.availabilityReason(astrologer.ID, medium); reason != "" {
		return reason, message
	}

	if freeMinutes == 0 {

		required := rate * s.repository.GetSystemFlagFloat(
			constants.FlagMinConsultationMinutes,
			defaultMinConsultationMinutes,
		)

		if balance < required {
			return "INSUFFICIENT_BALANCE", fmt.Sprintf(
				"insufficient wallet balance, %.2f required to start a %s session",
				required,
				strings.ToLower(medium),
			)
		}
	}

	return "", ""
}

//////////////////////////////////////////////////////////////
// Availability
//////////////////////////////////////////////////////////////

// availabilityReason is the gate that stops a customer paying to sit in a
// channel nobody is listening to.
//
// astrologer_status is the authoritative source: it is the only table the
// astrologer app actually writes, and the only one with a per-medium flag and
// a busy flag. Its current_status column is deliberately ignored — the
// dashboard writes "OFFLINE" into an enum that has no such member, which under
// a non-strict SQL mode silently stores the empty string, so only the booleans
// can be trusted.
//
// The last check is the one that decides. Steps above it read flags, which can
// drift across a crash; the consultations count reads the same table this flow
// writes, so it cannot.
func (s *consultationService) availabilityReason(
	astrologerID uint,
	medium string,
) (string, string) {

	availability, err := s.repository.GetAstrologerAvailability(astrologerID)

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

	strict := s.repository.GetSystemFlagFloat(
		constants.FlagConsultationAvailabilityStrict,
		0,
	) == 1

	if !availability.Found {

		// astrologer_status is empty on this database. Gating strictly on a
		// row that does not exist yet would reject every single request, so
		// the flag ships at 0 and is flipped once the astrologer apps have
		// each hit a status endpoint at least once.
		if strict {
			return "ASTROLOGER_OFFLINE", "astrologer is offline right now"
		}

	} else {

		if !availability.IsOnline {
			return "ASTROLOGER_OFFLINE", "astrologer is offline right now"
		}

		if availability.QuickDND {
			return "ASTROLOGER_BUSY", "astrologer is on do not disturb"
		}

		if medium == constants.MediumChat && !availability.IsChatOnline {
			return "ASTROLOGER_BUSY", "astrologer is not available for chat right now"
		}

		if medium != constants.MediumChat && !availability.IsCallOnline {
			return "ASTROLOGER_BUSY", "astrologer is not available for calls right now"
		}

		if availability.IsBusy {
			return "ASTROLOGER_BUSY", "astrologer is busy with another session"
		}
	}

	open, err := s.repository.CountOpenForAstrologer(astrologerID)

	if err == nil && open > 0 {
		return "ASTROLOGER_BUSY", "astrologer is busy with another session"
	}

	return "", ""
}

//////////////////////////////////////////////////////////////
// Request
//////////////////////////////////////////////////////////////

func (s *consultationService) StartConsultation(
	userID uint,
	request dto.StartConsultationRequest,
) (*dto.RequestConsultationResponse, error) {

	medium := strings.ToUpper(strings.TrimSpace(request.Medium))

	if !constants.IsValidMedium(medium) {
		return nil, errors.New("medium must be CHAT, AUDIO or VIDEO")
	}

	astrologer, err := s.repository.GetAstrologerByID(request.AstrologerID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	if astrologer.UserID == 0 {
		return nil, errors.New("astrologer account is not linked, cannot start a session")
	}

	if astrologer.UserID == userID {
		return nil, errors.New("you cannot consult yourself")
	}

	rate := rateForMedium(astrologer, medium)

	//------------------------------------------------
	// One Session At A Time
	//------------------------------------------------
	//
	// Covers REQUESTED as well as live sessions, so a customer cannot ring
	// several astrologers at once — and a session left open by a killed app
	// is closed here rather than locking them out.

	open, err := s.repository.GetOpenConsultation(userID)

	if err != nil {
		return nil, err
	}

	if open != nil && !s.expireIfLapsed(open) {

		return nil, fmt.Errorf(
			"a %s session is already open, finish it before starting another",
			strings.ToLower(open.Medium),
		)
	}

	balance, err := s.walletBalance(userID)

	if err != nil {
		return nil, err
	}

	freeMinutes, err := s.freeChatEntitlement(userID, medium)

	if err != nil {
		return nil, err
	}

	if reason, message := s.blockingReason(astrologer, medium, rate, balance, freeMinutes); reason != "" {
		return nil, errors.New(message)
	}

	//------------------------------------------------
	// The Waiting Queue Goes First
	//------------------------------------------------
	//
	// While a queued customer holds their turn, nobody else may request this
	// astrologer. queueRow is that customer's own row when it is them.

	queueRow, queueReason, queueMessage := s.queueGate(userID, astrologer)

	if queueReason != "" {
		return nil, errors.New(queueMessage)
	}

	//------------------------------------------------
	// The Quote
	//------------------------------------------------
	//
	// Written so the waiting screen has a number to show, and recomputed for
	// real when the customer starts the session: the balance can move while
	// the phone rings.

	quoted := freeMinutes * 60

	if freeMinutes == 0 {

		quoted = s.affordableSeconds(balance, rate)

		if quoted <= 0 {
			return nil, errors.New("insufficient wallet balance")
		}
	}

	now := time.Now()

	consultation := &models.Consultation{
		ConsultationNo:     consultationNumber(medium, now, userID),
		UserID:             userID,
		AstrologerID:       astrologer.ID,
		Medium:             medium,
		Status:             constants.ConsultationStatusRequested,
		RatePerMinute:      rate,
		RequestedAt:        &now,
		MaxBillableSeconds: quoted,
		SettlementStatus:   constants.SettlementStatusNA,
		Name:               strings.TrimSpace(request.Name),
		BirthTime:          strings.TrimSpace(request.BirthTime),
		BirthPlace:         strings.TrimSpace(request.BirthPlace),
		Gender:             strings.TrimSpace(request.Gender),
		CreatedAt:          &now,
		UpdatedAt:          &now,
	}

	if birthDate := parseDate(request.BirthDate); birthDate != nil {
		consultation.BirthDate = birthDate
	}

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

	//------------------------------------------------
	// The Channel
	//------------------------------------------------
	//
	// Derived from the row id, which is unique by construction.
	//
	// consultationNumber() is a timestamp plus the low four digits of the
	// user id, so two customers whose ids differ by a multiple of 10000 and
	// who start in the same second produce the same string. Harmless as a
	// reference number; a conversation leak if two unrelated pairs were put
	// in one Agora channel. The client no longer supplies it either.

	consultation.ChannelName = fmt.Sprintf("cs-%d", consultation.ID)

	if err := s.repository.SetChannelName(consultation.ID, consultation.ChannelName); err != nil {
		return nil, err
	}

	// Placed on the customer's queue turn: the row now follows this request.
	s.linkQueueToConsultation(queueRow, consultation.ID)

	notifications.Notify().ConsultationRequestReceived(
		astrologer.ID,
		userID,
		medium,
		consultation.ID,
		consultation.ConsultationNo,
	)

	ringTimeout := s.ringTimeoutSeconds()

	return &dto.RequestConsultationResponse{
		ConsultationID:           consultation.ID,
		ConsultationNo:           consultation.ConsultationNo,
		AstrologerID:             astrologer.ID,
		AstrologerName:           astrologerName(astrologer),
		AstrologerImage:          astrologer.ProfileImage,
		Medium:                   medium,
		Status:                   consultation.Status,
		IsFreeChat:               freeMinutes > 0,
		FreeMinutes:              freeMinutes,
		RatePerMinute:            rate,
		WalletBalance:            balance,
		QuotedMaxBillableSeconds: quoted,
		QuotedMaxBillableMinutes: quoted / 60,
		RequestedAt:              formatStamp(&now),
		RingExpiresAt:            now.Add(time.Duration(ringTimeout) * time.Second).Format(stampLayout),
		RingTimeoutSeconds:       ringTimeout,
		PollIntervalSeconds:      3,
	}, nil
}

//////////////////////////////////////////////////////////////
// Accept
//////////////////////////////////////////////////////////////

func (s *consultationService) AcceptConsultation(
	astrologerUserID uint,
	consultationID uint,
) (*dto.AcceptConsultationResponse, error) {

	astrologer, err := s.repository.GetAstrologerByUserID(astrologerUserID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	tx := s.repository.Begin()

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

	committed := false

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

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

	if err != nil {
		return nil, err
	}

	if locked == nil || locked.AstrologerID != astrologer.ID {
		return nil, errors.New("consultation not found")
	}

	if locked.Status != constants.ConsultationStatusRequested {
		return nil, errors.New("this consultation is no longer available")
	}

	now := time.Now()

	//------------------------------------------------
	// Too Late
	//------------------------------------------------
	//
	// Enforced here rather than left to the cron: an external scheduler
	// cannot run more than once a minute, and a request that rang out 50
	// seconds ago must not still be acceptable.

	if s.ringDeadline(locked).Before(now) {

		if err := s.closeUnbilled(tx, locked, constants.ConsultationStatusMissed, constants.EndReasonNoAnswer, now); err != nil {
			return nil, err
		}

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

		committed = true

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

		return nil, errors.New("this consultation is no longer available")
	}

	//------------------------------------------------
	// One Session Per Astrologer
	//------------------------------------------------
	//
	// Claimed under a lock, so two customers ringing at once cannot both be
	// accepted. The losers are closed here with a push rather than left
	// ringing until they time out — being told immediately is kinder than a
	// spinner that quietly expires.

	siblings, err := s.repository.LockOpenForAstrologer(tx, astrologer.ID)

	if err != nil {
		return nil, err
	}

	losers := make([]models.Consultation, 0, len(siblings))

	for _, sibling := range siblings {

		if sibling.ID == locked.ID {
			continue
		}

		if constants.IsLiveStatus(sibling.Status) {
			return nil, errors.New("you are already in a consultation")
		}

		losers = append(losers, sibling)
	}

	for index := range losers {

		loser := losers[index]

		if err := s.closeUnbilled(tx, &loser, constants.ConsultationStatusCancelled, constants.EndReasonCancelled, now); err != nil {
			return nil, err
		}
	}

	//------------------------------------------------
	// The Customer Must Still Be Able To Pay
	//------------------------------------------------

	freeMinutes := 0

	if locked.Medium == constants.MediumChat {

		entitled, err := s.freeChatEntitlementLocked(tx, locked.UserID, locked.ID)

		if err != nil {
			return nil, err
		}

		freeMinutes = entitled
	}

	if freeMinutes == 0 {

		balance, err := s.lockedBalance(tx, locked.UserID)

		if err != nil {
			return nil, err
		}

		required := locked.RatePerMinute * s.repository.GetSystemFlagFloat(
			constants.FlagMinConsultationMinutes,
			defaultMinConsultationMinutes,
		)

		if balance < required {

			if err := s.closeUnbilled(tx, locked, constants.ConsultationStatusCancelled, constants.EndReasonInsufficientBalance, now); err != nil {
				return nil, err
			}

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

			committed = true

			return nil, errors.New("the customer no longer has sufficient balance")
		}

		locked.MaxBillableSeconds = s.affordableSeconds(balance, locked.RatePerMinute)

	} else {

		locked.MaxBillableSeconds = freeMinutes * 60
	}

	//------------------------------------------------
	// Accept
	//------------------------------------------------
	//
	// startedAt stays NULL. elapsedSeconds() keys off it, so the billing
	// clock simply does not run until the customer confirms — no special case
	// anywhere in the billing path.

	locked.Status = constants.ConsultationStatusAccepted
	locked.AcceptedAt = &now
	locked.FreeMinutes = freeMinutes
	locked.UpdatedAt = &now

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

	if err := s.repository.SetAstrologerBusy(tx, astrologer.ID, true); err != nil {
		return nil, err
	}

	// Placed from the waiting queue: CONNECTING -> CONNECTED.
	s.syncQueue(tx, locked)

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

		// The unique index on the generated free-chat claim column is the
		// last line of defence against two sessions both claiming a
		// customer's one free chat. Refuse rather than quietly downgrading to
		// paid: somebody who tapped "2 minutes free" must not be billed
		// without being asked.
		if isDuplicateKey(err) {
			return nil, errors.New("this free session is no longer available")
		}

		return nil, err
	}

	committed = true

	//------------------------------------------------
	// Tell Everyone
	//------------------------------------------------

	notifications.Notify().ConsultationAccepted(
		locked.UserID,
		locked.AstrologerID,
		locked.Medium,
		locked.ID,
		locked.ConsultationNo,
	)

	for index := range losers {

		notifications.Notify().ConsultationCancelledByAstrologer(
			losers[index].UserID,
			losers[index].AstrologerID,
			losers[index].Medium,
			losers[index].ID,
		)
	}

	credentials := s.credentials(locked, astrologer.UserID, AgoraPartyAstrologer)

	customerName, customerImage := s.customerIdentity(locked.UserID)

	joinTimeout := s.joinTimeoutSeconds()

	return &dto.AcceptConsultationResponse{
		ConsultationID:           locked.ID,
		ConsultationNo:           locked.ConsultationNo,
		UserID:                   locked.UserID,
		UserName:                 customerName,
		UserImage:                customerImage,
		Medium:                   locked.Medium,
		Status:                   locked.Status,
		IsFreeChat:               freeMinutes > 0,
		FreeMinutes:              freeMinutes,
		RatePerMinute:            locked.RatePerMinute,
		EstimatedEarning:         s.estimatedEarning(locked),
		QuotedMaxBillableSeconds: locked.MaxBillableSeconds,
		QuotedMaxBillableMinutes: locked.MaxBillableSeconds / 60,
		AcceptedAt:               formatStamp(&now),
		ServerTime:               formatStamp(&now),
		JoinExpiresAt:            now.Add(time.Duration(joinTimeout) * time.Second).Format(stampLayout),
		SecondsToJoinEnd:         joinTimeout,
		ChannelName:              locked.ChannelName,
		ConsulteeName:            locked.Name,
		ConsulteeBirthDate:       formatDate(locked.BirthDate),
		ConsulteeBirthTime:       locked.BirthTime,
		ConsulteeBirthPlace:      locked.BirthPlace,
		ConsulteeGender:          locked.Gender,
		Agora:                    credentials,
	}, nil
}

//////////////////////////////////////////////////////////////
// Reject
//////////////////////////////////////////////////////////////

func (s *consultationService) RejectConsultation(
	astrologerUserID uint,
	consultationID uint,
	reason string,
) (*dto.EndConsultationResponse, error) {

	astrologer, err := s.repository.GetAstrologerByUserID(astrologerUserID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	closed, err := s.closeRequested(
		consultationID,
		func(record *models.Consultation) bool { return record.AstrologerID == astrologer.ID },
		constants.ConsultationStatusRejected,
		constants.EndReasonRejected,
		strings.TrimSpace(reason),
	)

	if err != nil {
		return nil, err
	}

	notifications.Notify().ConsultationRejected(
		closed.UserID,
		closed.AstrologerID,
		closed.Medium,
		closed.ID,
		strings.TrimSpace(reason),
	)

	return s.receipt(closed, 0), nil
}

//////////////////////////////////////////////////////////////
// Cancel
//////////////////////////////////////////////////////////////

func (s *consultationService) CancelConsultation(
	userID uint,
	consultationID uint,
) (*dto.EndConsultationResponse, error) {

	closed, err := s.closeRequested(
		consultationID,
		func(record *models.Consultation) bool { return record.UserID == userID },
		constants.ConsultationStatusCancelled,
		constants.EndReasonCancelled,
		"",
	)

	if err != nil {
		return nil, err
	}

	notifications.Notify().ConsultationCancelledByCustomer(
		closed.AstrologerID,
		closed.UserID,
		closed.Medium,
		closed.ID,
	)

	return s.receipt(closed, 0), nil
}

// closeRequested moves a still-ringing session to a terminal state under a
// lock. Rejecting, cancelling and timing out are the same write with a
// different label, and the lock is what stops two of them both winning.
func (s *consultationService) closeRequested(
	consultationID uint,
	isParty func(*models.Consultation) bool,
	status string,
	reason string,
	remarks string,
) (*models.Consultation, error) {

	tx := s.repository.Begin()

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

	committed := false

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

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

	if err != nil {
		return nil, err
	}

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

	if locked.Status != constants.ConsultationStatusRequested {

		if constants.IsLiveStatus(locked.Status) {
			return nil, errors.New("this consultation has already been accepted")
		}

		return nil, errors.New("this consultation is no longer available")
	}

	now := time.Now()

	if remarks != "" {
		locked.AdminRemarks = truncate(remarks, 255)
	}

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

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

	committed = true

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

	return locked, nil
}

//////////////////////////////////////////////////////////////
// Start (the customer's confirmation)
//////////////////////////////////////////////////////////////

func (s *consultationService) StartChat(
	userID uint,
	consultationID uint,
) (*dto.StartChatResponse, error) {

	tx := s.repository.Begin()

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

	committed := false

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

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

	if err != nil {
		return nil, err
	}

	if locked == nil || locked.UserID != userID {
		return nil, errors.New("consultation not found")
	}

	now := time.Now()

	//------------------------------------------------
	// Already Running
	//------------------------------------------------
	//
	// A retry after a dropped response must not restart the clock.

	if locked.Status == constants.ConsultationStatusOngoing {

		tx.Rollback()
		committed = true

		return s.startChatResponse(locked, now)
	}

	if locked.Status != constants.ConsultationStatusAccepted {
		return nil, errors.New("this consultation is not ready to start")
	}

	if s.joinDeadline(locked).Before(now) {

		if err := s.closeUnbilled(tx, locked, constants.ConsultationStatusCancelled, constants.EndReasonNotJoined, now); err != nil {
			return nil, err
		}

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

		committed = true

		return nil, errors.New("this consultation expired before it was opened")
	}

	//------------------------------------------------
	// The Binding Cap
	//------------------------------------------------
	//
	// Recomputed here, against the wallet as it stands at the instant money
	// starts being spent. The quotes at request and accept are advisory: the
	// balance can drop in between on an astromall order or a report, and the
	// warning keys off this number, so a stale cap would cut the customer off
	// mid-sentence without a warning.

	if locked.FreeMinutes > 0 {

		locked.MaxBillableSeconds = locked.FreeMinutes * 60
		locked.FreeSeconds = locked.MaxBillableSeconds

	} else {

		balance, err := s.lockedBalance(tx, locked.UserID)

		if err != nil {
			return nil, err
		}

		required := locked.RatePerMinute * s.repository.GetSystemFlagFloat(
			constants.FlagMinConsultationMinutes,
			defaultMinConsultationMinutes,
		)

		if balance < required {

			if err := s.closeUnbilled(tx, locked, constants.ConsultationStatusCancelled, constants.EndReasonInsufficientBalance, now); err != nil {
				return nil, err
			}

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

			committed = true

			return nil, fmt.Errorf(
				"insufficient wallet balance, %.2f required to start a %s session",
				required,
				strings.ToLower(locked.Medium),
			)
		}

		locked.MaxBillableSeconds = s.cappedSeconds(s.affordableSeconds(balance, locked.RatePerMinute))
	}

	locked.Status = constants.ConsultationStatusOngoing
	locked.StartedAt = &now
	locked.LastTickAt = &now
	locked.UpdatedAt = &now

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

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

	committed = true

	return s.startChatResponse(locked, now)
}

func (s *consultationService) startChatResponse(
	consultation *models.Consultation,
	now time.Time,
) (*dto.StartChatResponse, error) {

	astrologer, err := s.repository.GetAstrologerByID(consultation.AstrologerID)

	if err != nil {
		return nil, err
	}

	balance, err := s.walletBalance(consultation.UserID)

	if err != nil {
		return nil, err
	}

	response := &dto.StartChatResponse{
		ConsultationID:      consultation.ID,
		ConsultationNo:      consultation.ConsultationNo,
		AstrologerID:        consultation.AstrologerID,
		Medium:              consultation.Medium,
		Status:              consultation.Status,
		IsFreeChat:          consultation.FreeMinutes > 0,
		FreeMinutes:         consultation.FreeMinutes,
		RatePerMinute:       consultation.RatePerMinute,
		WalletBalance:       balance,
		MaxBillableSeconds:  consultation.MaxBillableSeconds,
		MaxBillableMinutes:  consultation.MaxBillableSeconds / 60,
		StartedAt:           formatStamp(consultation.StartedAt),
		ServerTime:          formatStamp(&now),
		ChannelName:         consultation.ChannelName,
		TickIntervalSeconds: defaultTickIntervalSeconds,
		TickTimeoutSeconds:  s.tickTimeoutSeconds(consultation.Medium),
		WarningSeconds:      s.warningSeconds(),
	}

	if astrologer != nil {
		response.AstrologerName = astrologerName(astrologer)
		response.AstrologerImage = astrologer.ProfileImage
		response.Agora = s.credentials(consultation, astrologer.UserID, AgoraPartyCustomer)
	}

	return response, nil
}

//////////////////////////////////////////////////////////////
// Status
//////////////////////////////////////////////////////////////

func (s *consultationService) GetConsultationStatus(
	userID uint,
	consultationID uint,
) (*dto.ConsultationStatusResponse, error) {

	consultation, err := s.repository.GetConsultationByID(consultationID)

	if err != nil {
		return nil, err
	}

	if consultation == nil || consultation.UserID != userID {
		return nil, errors.New("consultation not found")
	}

	// Expires its own row, so the customer's countdown and the server agree
	// without waiting for a cron that cannot run more than once a minute.
	s.expireIfLapsed(consultation)

	return s.statusResponse(consultation, AgoraPartyCustomer)
}

func (s *consultationService) GetAstrologerActiveConsultation(
	astrologerUserID uint,
) (*dto.ConsultationStatusResponse, error) {

	astrologer, err := s.repository.GetAstrologerByUserID(astrologerUserID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	records, err := s.repository.LockOpenForAstrologer(nil, astrologer.ID)

	if err != nil {
		return nil, err
	}

	for index := range records {

		record := records[index]

		// Read-only on this side: the astrologer is not the party whose ticks
		// define liveness, so this endpoint must never bill or close a stale
		// session the way the customer's /active does.
		if constants.IsOpenStatus(record.Status) {
			return s.statusResponse(&record, AgoraPartyAstrologer)
		}
	}

	return &dto.ConsultationStatusResponse{Status: ""}, nil
}

func (s *consultationService) statusResponse(
	consultation *models.Consultation,
	party string,
) (*dto.ConsultationStatusResponse, error) {

	astrologer, err := s.repository.GetAstrologerByID(consultation.AstrologerID)

	if err != nil {
		return nil, err
	}

	balance, err := s.walletBalance(consultation.UserID)

	if err != nil {
		return nil, err
	}

	now := time.Now()

	response := &dto.ConsultationStatusResponse{
		ConsultationID: consultation.ID,
		ConsultationNo: consultation.ConsultationNo,
		Status:         consultation.Status,
		EndReason:      consultation.EndReason,
		AstrologerID:   consultation.AstrologerID,
		Medium:         consultation.Medium,
		IsFreeChat:     consultation.FreeMinutes > 0,
		FreeMinutes:    consultation.FreeMinutes,
		RatePerMinute:  consultation.RatePerMinute,
		WalletBalance:  balance,
		RequestedAt:    formatStamp(consultation.RequestedAt),
		AcceptedAt:     formatStamp(consultation.AcceptedAt),
		StartedAt:      formatStamp(consultation.StartedAt),
	}

	if astrologer != nil {
		response.AstrologerName = astrologerName(astrologer)
		response.AstrologerImage = astrologer.ProfileImage
	}

	switch consultation.Status {

	case constants.ConsultationStatusRequested:

		deadline := s.ringDeadline(consultation)

		response.RingExpiresAt = deadline.Format(stampLayout)
		response.SecondsToExpiry = secondsUntil(deadline, now)

	case constants.ConsultationStatusAccepted:

		deadline := s.joinDeadline(consultation)

		response.JoinExpiresAt = deadline.Format(stampLayout)
		response.SecondsToJoinEnd = secondsUntil(deadline, now)
		response.ChannelName = consultation.ChannelName
		response.MaxBillableSeconds = consultation.MaxBillableSeconds

		// The astrologer has committed and joined; the customer has not
		// agreed to be charged yet, so only the astrologer is handed
		// credentials at this point.
		if party == AgoraPartyAstrologer && astrologer != nil {
			response.Agora = s.credentials(consultation, astrologer.UserID, party)
		}

	case constants.ConsultationStatusOngoing:

		elapsed := elapsedSeconds(consultation, now)

		response.ElapsedSeconds = elapsed
		response.RemainingSeconds = remainingFor(consultation, elapsed)
		response.MaxBillableSeconds = consultation.MaxBillableSeconds
		response.CurrentCharge = s.currentCharge(consultation, elapsed)
		response.ChannelName = consultation.ChannelName
		response.TickIntervalSeconds = defaultTickIntervalSeconds
		response.TickTimeoutSeconds = s.tickTimeoutSeconds(consultation.Medium)
		response.WarningSeconds = s.warningSeconds()

		if astrologer != nil {
			response.Agora = s.credentials(consultation, astrologer.UserID, party)
		}

	default:

		if consultation.EndedAt != nil {
			response.Receipt = s.receipt(consultation, 0)
		}
	}

	return response, nil
}

//////////////////////////////////////////////////////////////
// The astrologer's ringing queue
//////////////////////////////////////////////////////////////

func (s *consultationService) ListIncomingRequests(
	astrologerUserID uint,
) (*dto.IncomingRequestsResponse, error) {

	astrologer, err := s.repository.GetAstrologerByUserID(astrologerUserID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	records, err := s.repository.ListAstrologerRequests(astrologer.ID, 20)

	if err != nil {
		return nil, err
	}

	now := time.Now()

	items := make([]dto.IncomingRequestItem, 0, len(records))

	expired := 0

	for index := range records {

		record := records[index]

		// Expired rows are closed on read rather than shown with a negative
		// countdown, so the queue never offers something that cannot be
		// accepted. Counted, not swallowed — see ExpiredCount.
		if s.expireIfLapsed(&record) {
			expired++
			continue
		}

		name, image := s.customerIdentity(record.UserID)

		deadline := s.ringDeadline(&record)

		items = append(items, dto.IncomingRequestItem{
			ConsultationID:           record.ID,
			ConsultationNo:           record.ConsultationNo,
			UserID:                   record.UserID,
			UserName:                 name,
			UserImage:                image,
			Medium:                   record.Medium,
			Status:                   record.Status,
			IsFreeChat:               record.FreeMinutes > 0 || s.wouldBeFree(record),
			FreeMinutes:              s.displayFreeMinutes(record),
			RatePerMinute:            record.RatePerMinute,
			EstimatedEarning:         s.estimatedEarning(&record),
			QuotedMaxBillableSeconds: record.MaxBillableSeconds,
			QuotedMaxBillableMinutes: record.MaxBillableSeconds / 60,
			ConsulteeName:            record.Name,
			ConsulteeBirthDate:       formatDate(record.BirthDate),
			ConsulteeBirthTime:       record.BirthTime,
			ConsulteeBirthPlace:      record.BirthPlace,
			ConsulteeGender:          record.Gender,
			RequestedAt:              formatStamp(record.RequestedAt),
			RingExpiresAt:            deadline.Format(stampLayout),
			SecondsToExpiry:          secondsUntil(deadline, now),
		})
	}

	return &dto.IncomingRequestsResponse{
		Items:               items,
		Count:               len(items),
		ExpiredCount:        expired,
		RingTimeoutSeconds:  s.ringTimeoutSeconds(),
		ServerTime:          formatStamp(&now),
		PollIntervalSeconds: 3,
	}, nil
}

//////////////////////////////////////////////////////////////
// Continue a free chat on the paid rate
//////////////////////////////////////////////////////////////

// ContinuePaid is what the customer taps when the free minutes are about to
// run out and they want to keep talking.
//
// It does not restart anything: the session carries on, freeSeconds keeps a
// record of what the platform funded, and the cap grows to cover the paid time
// the wallet can now pay for. Billing splits the two portions at the end.
func (s *consultationService) ContinuePaid(
	userID uint,
	consultationID uint,
) (*dto.TickResponse, error) {

	return s.raiseCap(userID, consultationID, true)
}

// ExtendConsultation re-reads the wallet after a recharge.
//
// Without it the "please recharge to continue" warning is decorative — nothing
// else in this service ever raises a running session's cap, so a customer who
// recharged mid-session would still be cut off on the old one.
func (s *consultationService) ExtendConsultation(
	userID uint,
	consultationID uint,
) (*dto.TickResponse, error) {

	return s.raiseCap(userID, consultationID, false)
}

func (s *consultationService) raiseCap(
	userID uint,
	consultationID uint,
	convertFree bool,
) (*dto.TickResponse, error) {

	tx := s.repository.Begin()

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

	committed := false

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

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

	if err != nil {
		return nil, err
	}

	if locked == nil || locked.UserID != userID {
		return nil, errors.New("consultation not found")
	}

	if !constants.IsLiveStatus(locked.Status) {
		return nil, errors.New("consultation is not running")
	}

	balance, err := s.lockedBalance(tx, locked.UserID)

	if err != nil {
		return nil, err
	}

	required := locked.RatePerMinute * s.repository.GetSystemFlagFloat(
		constants.FlagMinConsultationMinutes,
		defaultMinConsultationMinutes,
	)

	if balance < required {
		return nil, fmt.Errorf(
			"insufficient wallet balance, %.2f required to continue",
			required,
		)
	}

	affordable := s.affordableSeconds(balance, locked.RatePerMinute)

	now := time.Now()

	if convertFree && locked.FreeMinutes > 0 {

		// The free portion is fixed at whatever the allowance was; everything
		// past it is the customer's own money.
		if locked.FreeSeconds <= 0 {
			locked.FreeSeconds = locked.FreeMinutes * 60
		}

		locked.MaxBillableSeconds = s.cappedSeconds(locked.FreeSeconds + affordable)

	} else {

		// A cap must never shrink under a live session: the customer has
		// already been told how long they have.
		if raised := s.cappedSeconds(locked.FreeSeconds + affordable); raised > locked.MaxBillableSeconds {
			locked.MaxBillableSeconds = raised
		}
	}

	// Let the warning fire again against the new cap.
	locked.WarnedAt = nil
	locked.UpdatedAt = &now

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

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

	committed = true

	return s.Tick(userID, consultationID)
}

//////////////////////////////////////////////////////////////
// Tokens
//////////////////////////////////////////////////////////////

func (s *consultationService) CustomerAgoraToken(
	userID uint,
	consultationID uint,
) (*dto.AgoraCredentials, error) {

	consultation, err := s.repository.GetConsultationByID(consultationID)

	if err != nil {
		return nil, err
	}

	if consultation == nil || consultation.UserID != userID {
		return nil, errors.New("consultation not found")
	}

	return s.tokenFor(consultation, AgoraPartyCustomer)
}

func (s *consultationService) AstrologerAgoraToken(
	astrologerUserID uint,
	consultationID uint,
) (*dto.AgoraCredentials, error) {

	astrologer, err := s.repository.GetAstrologerByUserID(astrologerUserID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	consultation, err := s.repository.GetConsultationByID(consultationID)

	if err != nil {
		return nil, err
	}

	if consultation == nil || consultation.AstrologerID != astrologer.ID {
		return nil, errors.New("consultation not found")
	}

	return s.tokenFor(consultation, AgoraPartyAstrologer)
}

func (s *consultationService) tokenFor(
	consultation *models.Consultation,
	party string,
) (*dto.AgoraCredentials, error) {

	if !constants.IsLiveStatus(consultation.Status) {
		return nil, errors.New("consultation is not live")
	}

	if s.agora == nil || !s.agora.Enabled() {
		return nil, errors.New("consultation service is not configured")
	}

	astrologer, err := s.repository.GetAstrologerByID(consultation.AstrologerID)

	if err != nil {
		return nil, err
	}

	if astrologer == nil {
		return nil, errors.New("astrologer not found")
	}

	return s.agora.CredentialsFor(consultation, astrologer.UserID, party)
}

// credentials mints a block for a response, swallowing the error.
//
// A session that is otherwise fine must not fail because Agora is
// misconfigured: the apps see a missing block and can say so, which is a much
// better failure than a 500 on accept.
func (s *consultationService) credentials(
	consultation *models.Consultation,
	astrologerUserID uint,
	party string,
) *dto.AgoraCredentials {

	if s.agora == nil || !s.agora.Enabled() {
		return nil
	}

	issued, err := s.agora.CredentialsFor(consultation, astrologerUserID, party)

	if err != nil {
		return nil
	}

	return issued
}
