package services

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

	"gorm.io/gorm"
)

const (
	defaultPlatformCommissionPercent   = 20.0
	defaultMinConsultationMinutes      = 2.0
	defaultConsultationGraceSeconds    = 10.0
	defaultTickTimeoutSeconds          = 120.0
	defaultChatTickTimeoutSeconds      = 240.0
	defaultRingTimeoutSeconds          = 60.0
	defaultJoinTimeoutSeconds          = 60.0
	defaultFreeChatMinutes             = 2.0
	defaultFreeChatMinConnectedSeconds = 10.0
	defaultConsultationMaxMinutes      = 60.0
	lowBalanceWarningSeconds           = 30
	defaultTickIntervalSeconds         = 30
)

type ConsultationService interface {

	//------------------------------------------------
	// Customer
	//------------------------------------------------

	// Precheck renders the "Start chat" sheet: whether the session can start
	// at all, whether it is free, and how long the balance would pay for.
	Precheck(userID uint, astrologerID uint, medium string) (*dto.PrecheckResponse, error)
	StartConsultation(
		userID uint,
		request dto.StartConsultationRequest,
	) (*dto.RequestConsultationResponse, error)
	StartChat(userID uint, consultationID uint) (*dto.StartChatResponse, error)
	CancelConsultation(userID uint, consultationID uint) (*dto.EndConsultationResponse, error)

	// GetConsultationStatus is polled while the request rings. It expires its
	// own row, so the customer's countdown does not wait on the cron.
	GetConsultationStatus(userID uint, consultationID uint) (*dto.ConsultationStatusResponse, error)

	// ContinuePaid carries a free chat on at the astrologer's rate when the
	// customer taps Continue at the warning.
	ContinuePaid(userID uint, consultationID uint) (*dto.TickResponse, error)

	// ExtendConsultation re-reads the wallet after a recharge and raises the
	// cap. Without it the "please recharge" warning is decorative: nothing
	// else extends a running session.
	ExtendConsultation(userID uint, consultationID uint) (*dto.TickResponse, error)

	CustomerAgoraToken(userID uint, consultationID uint) (*dto.AgoraCredentials, error)

	Tick(userID uint, consultationID uint) (*dto.TickResponse, error)

	//------------------------------------------------
	// Waiting queue (consultation_queue_service.go)
	//------------------------------------------------

	JoinQueue(userID uint, request dto.JoinQueueRequest) (*dto.QueueEntryResponse, error)
	GetQueueStatus(userID uint, queueID uint) (*dto.QueueListResponse, error)
	ConnectQueue(userID uint, queueID uint) (*dto.QueueEntryResponse, error)
	CancelQueue(userID uint, queueID uint) (*dto.QueueEntryResponse, error)

	//------------------------------------------------
	// Astrologer
	//------------------------------------------------

	ListIncomingRequests(astrologerUserID uint) (*dto.IncomingRequestsResponse, error)

	AcceptConsultation(astrologerUserID uint, consultationID uint) (*dto.AcceptConsultationResponse, error)

	RejectConsultation(
		astrologerUserID uint,
		consultationID uint,
		reason string,
	) (*dto.EndConsultationResponse, error)

	GetAstrologerActiveConsultation(astrologerUserID uint) (*dto.ConsultationStatusResponse, error)

	AstrologerAgoraToken(astrologerUserID uint, consultationID uint) (*dto.AgoraCredentials, error)

	EndConsultation(
		userID uint,
		request dto.EndConsultationRequest,
	) (*dto.EndConsultationResponse, error)

	// EndConsultationByAstrologer is the astrologer app hanging up. It bills
	// through the same path as the customer ending it — the astrologer stack
	// calls into this service rather than owning a second billing
	// implementation.
	EndConsultationByAstrologer(
		astrologerUserID uint,
		consultationID uint,
	) (*dto.EndConsultationResponse, error)

	// EndConsultationByAdmin is support closing a stuck session from the
	// panel. It bills through the same path as everything else — a session an
	// admin ended has to move the money exactly as the customer hanging up
	// would, and a second implementation is how the two drift apart.
	//
	// ADMIN_ENDED is deliberately not in the set of reasons a client may
	// claim, so this is the only way that reason can be written.
	EndConsultationByAdmin(consultationID uint) (*dto.EndConsultationResponse, error)

	GetActiveConsultation(userID uint) (*dto.ActiveConsultationResponse, error)

	GetHistory(
		userID uint,
		medium string,
		page int,
		limit int,
	) (*dto.ConsultationHistoryResponse, error)

	// SweepStaleConsultations closes and bills sessions whose ticks stopped
	// arriving. It is exported because the admin side schedules it — an
	// abandoned session is billed by exactly the same code path as one the
	// customer ended, so there is one billing implementation, not two.
	SweepStaleConsultations(limit int) (int, error)

	// Sweep is the whole set of passes the admin panel's cron drives:
	// warnings, sessions past their cap, abandoned sessions, expired
	// requests, accepted sessions the customer never opened, and the busy
	// flag reconcile.
	//
	// Every pass is idempotent and re-entrant, so calling it twice at once is
	// safe, and a pass that fails is recorded rather than aborting the rest.
	Sweep(limit int) (*dto.SweepResult, error)
}

type consultationService struct {
	repository repositories.ConsultationRepository

	// Nil is legitimate: with no Agora credentials configured the flow still
	// runs end to end and the apps simply get no credentials back.
	agora AgoraTokenService
}

func NewConsultationService(
	repository repositories.ConsultationRepository,
	agora AgoraTokenService,
) ConsultationService {

	return &consultationService{
		agora:      agora,
		repository: repository,
	}
}

//////////////////////////////////////////////////////////////
// Tick
//////////////////////////////////////////////////////////////

func (s *consultationService) Tick(
	userID uint,
	consultationID uint,
) (*dto.TickResponse, 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")
	}

	now := time.Now()

	balance, err := s.walletBalance(userID)

	if err != nil {
		return nil, err
	}

	base := &dto.TickResponse{
		ConsultationID:      consultation.ID,
		Status:              consultation.Status,
		WalletBalance:       balance,
		IsFreeChat:          consultation.FreeMinutes > 0,
		TickIntervalSeconds: defaultTickIntervalSeconds,
		TickTimeoutSeconds:  s.tickTimeoutSeconds(consultation.Medium),
		ServerTime:          now.Format(stampLayout),
	}

	//------------------------------------------------
	// Still Waiting
	//------------------------------------------------
	//
	// A REQUESTED or ACCEPTED session has no clock yet. Before the handshake
	// existed anything that was not ONGOING got "session already ended" and a
	// hang-up instruction, which would now tell a customer to abandon a
	// request that is still ringing.

	if constants.IsPendingStatus(consultation.Status) ||
		(consultation.Status == constants.ConsultationStatusAccepted && consultation.StartedAt == nil) {

		// Lazily close it if it rang or waited too long, so the app is told
		// straight away rather than on the cron's next minute.
		if s.expireIfLapsed(consultation) {

			base.Status = consultation.Status
			base.ShouldDisconnect = true
			base.DisconnectReason = "session already ended"

			return base, nil
		}

		return base, nil
	}

	if !isLive(consultation.Status) {

		base.ElapsedSeconds = consultation.DurationSeconds
		base.CurrentCharge = consultation.GrossAmount
		base.ShouldDisconnect = true
		base.DisconnectReason = "session already ended"

		return base, nil
	}

	//------------------------------------------------
	// Keep Alive
	//------------------------------------------------

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

	elapsed := elapsedSeconds(consultation, now)

	remaining := remainingFor(consultation, elapsed)

	base.ElapsedSeconds = elapsed
	base.RemainingSeconds = remaining
	base.CurrentCharge = s.currentCharge(consultation, elapsed)
	base.WarningSeconds = s.warningSeconds()

	//------------------------------------------------
	// The Ending Warning
	//------------------------------------------------
	//
	// Claimed with a conditional UPDATE rather than derived from where the
	// previous tick stood. The old trick could not warn a backgrounded app at
	// all, and could never reach the astrologer, who does not tick — the
	// claim lets the sweep send the same warning when the app is silent, and
	// still guarantees exactly one each way.

	if remaining > 0 && remaining <= base.WarningSeconds {

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

		if err == nil && claimed {

			base.Warning = true
			base.WarningType = warningTypeFor(consultation)

			s.notifyEndingSoon(consultation, remaining)
		}
	}

	//------------------------------------------------
	// Time Is Up
	//------------------------------------------------
	//
	// The app is expected to hang up and call end. If it does not, the
	// sweeper closes the session and bills the same capped amount anyway —
	// bill() clamps to maxBillableSeconds, so a late close costs the customer
	// nothing extra.

	if remaining <= 0 {

		base.ShouldDisconnect = true

		if consultation.FreeMinutes > 0 {
			base.DisconnectReason = "free chat minutes used"
		} else {
			base.DisconnectReason = "wallet balance exhausted"
		}
	}

	return base, nil
}

// warningTypeFor says which of the three endings is approaching, because the
// customer's options differ. Running out of free minutes offers "continue at
// the astrologer's rate"; running out of money offers a recharge; hitting the
// platform ceiling offers nothing at all.
func warningTypeFor(consultation *models.Consultation) string {

	if consultation.FreeMinutes > 0 &&
		consultation.FreeSeconds >= consultation.MaxBillableSeconds {

		return "FREE_CHAT_ENDING"
	}

	return "LOW_BALANCE"
}

//////////////////////////////////////////////////////////////
// End
//////////////////////////////////////////////////////////////

func (s *consultationService) EndConsultation(
	userID uint,
	request dto.EndConsultationRequest,
) (*dto.EndConsultationResponse, error) {

	consultation, err := s.repository.GetConsultationByID(request.ConsultationID)

	if err != nil {
		return nil, err
	}

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

	//------------------------------------------------
	// End Reason
	//------------------------------------------------
	//
	// Only the three a client can legitimately claim. ABANDONED, REJECTED
	// and CANCELLED are written by the server.

	reason := strings.ToUpper(strings.TrimSpace(request.EndReason))

	switch reason {

	case "":
		reason = constants.EndReasonUserEnded

	case constants.EndReasonUserEnded,
		constants.EndReasonAstrologerEnded,
		constants.EndReasonInsufficientBalance:

	default:
		return nil, errors.New("invalid end reason")
	}

	return s.bill(consultation, reason, request.ClientDurationSeconds)
}

//////////////////////////////////////////////////////////////
// End (astrologer)
//////////////////////////////////////////////////////////////

// EndConsultationByAstrologer closes a session from the astrologer's side.
// The astrologer must be a party to it — ending somebody else's session by
// guessing an id is the one thing this has to prevent.
func (s *consultationService) EndConsultationByAstrologer(
	astrologerUserID uint,
	consultationID uint,
) (*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")
	}

	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.bill(consultation, constants.EndReasonAstrologerEnded, 0)
}

//////////////////////////////////////////////////////////////
// End (admin)
//////////////////////////////////////////////////////////////

// EndConsultationByAdmin closes a session from the admin panel.
//
// No party check, because support is not a party — the authorisation is the
// admin middleware on the route. Everything else is identical to either side
// hanging up, including the wallet debit, the platform fee and parking the
// astrologer's earning at PENDING.
func (s *consultationService) EndConsultationByAdmin(
	consultationID uint,
) (*dto.EndConsultationResponse, error) {

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

	if err != nil {
		return nil, err
	}

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

	return s.bill(consultation, constants.EndReasonAdminEnded, 0)
}

//////////////////////////////////////////////////////////////
// Billing
//////////////////////////////////////////////////////////////

// bill closes a session and moves the money, in one transaction:
//
//	customer wallet  -= gross
//	wallettransaction    debit row, so it shows in the customer's history
//	platform             keeps platformFeeAmount
//	astrologer           is owed astrologerEarning, at PENDING
//
// It is the single billing path: the customer ending a chat, the balance
// running out, and the sweeper finding an abandoned session all come through
// here, so the three cannot drift apart.
//
// Re-entrant by design — an already-closed session returns its existing
// receipt instead of billing twice.
func (s *consultationService) bill(
	consultation *models.Consultation,
	reason string,
	clientSeconds int,
) (*dto.EndConsultationResponse, error) {

	tx := s.repository.Begin()

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

	committed := false

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

	//------------------------------------------------
	// Re-read Under Lock
	//------------------------------------------------

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

	if err != nil {
		return nil, err
	}

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

	if !isLive(locked.Status) {

		// Somebody else already billed it. Report what they wrote.
		tx.Rollback()
		committed = true

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

	now := time.Now()

	//------------------------------------------------
	// Duration
	//------------------------------------------------

	elapsed := elapsedSeconds(locked, now)

	// A client may shorten its own bill but never lengthen it.
	if clientSeconds > 0 && clientSeconds < elapsed {
		elapsed = clientSeconds
	}

	billable := elapsed

	if locked.MaxBillableSeconds > 0 && billable > locked.MaxBillableSeconds {
		billable = locked.MaxBillableSeconds
	}

	grace := int(s.repository.GetSystemFlagFloat(
		constants.FlagConsultationGraceSeconds,
		defaultConsultationGraceSeconds,
	))

	//------------------------------------------------
	// A Session That Never Really Connected
	//------------------------------------------------

	if billable <= grace {

		locked.Status = constants.ConsultationStatusCompleted
		locked.EndReason = reason
		locked.EndedAt = &now
		locked.DurationSeconds = elapsed
		locked.BilledSeconds = 0
		locked.BilledMinutes = 0
		locked.GrossAmount = 0
		locked.PlatformFeeAmount = 0
		locked.AstrologerEarning = 0
		locked.SettlementStatus = constants.SettlementStatusNA
		locked.UpdatedAt = &now

		// Nobody spoke, so nobody used their free chat. Clearing this hands
		// the entitlement back and releases the claim the unique index holds.
		locked.FreeMinutes = 0
		locked.FreeSeconds = 0

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

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

		s.syncQueue(tx, locked)

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

		committed = true

		_, _ = s.advanceQueue(locked.AstrologerID)

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

	//------------------------------------------------
	// Free And Paid Portions
	//------------------------------------------------
	//
	// Three shapes, one piece of arithmetic. A pure free chat has no paid
	// portion; a paid session has no free one; and a free chat the customer
	// chose to continue at the warning has both — the platform funds
	// everything up to freeSeconds and the customer pays for the rest.

	freeSeconds := locked.FreeSeconds

	if locked.FreeMinutes > 0 && freeSeconds <= 0 {
		freeSeconds = locked.FreeMinutes * 60
	}

	if freeSeconds > billable {
		freeSeconds = billable
	}

	if freeSeconds < 0 {
		freeSeconds = 0
	}

	freeMinutes := locked.FreeMinutes

	paidSeconds := billable - freeSeconds

	if paidSeconds < 0 {
		paidSeconds = 0
	}

	// A free session that dropped in its first seconds pays nobody and hands
	// the customer their free chat back. Clearing freeMinutes is what
	// releases the claim, so the "has this customer used their free chat"
	// query and the unique index on the generated claim column never
	// disagree.
	//
	// paidSeconds has to go to zero with it. Releasing the allowance on its
	// own would reclassify those same seconds as paid and bill a whole minute
	// for a free chat that lasted three seconds — the customer would be
	// charged for the session they were promised for nothing, which is the
	// worst possible outcome of a rule meant to protect them. Grace normally
	// catches this first; it must not be the only thing that does.
	if freeMinutes > 0 && billable < s.freeChatMinConnectedSeconds() {

		freeMinutes = 0
		freeSeconds = 0
		paidSeconds = 0
	}

	//------------------------------------------------
	// Charge
	//------------------------------------------------
	//
	// Part-minutes round up on both portions, which is how the rate was
	// quoted per minute. The customer is only ever charged for paidSeconds.

	billedMinutes := minutesFor(paidSeconds)

	gross := round2(float64(billedMinutes) * locked.RatePerMinute)

	// What the platform owes the astrologer for the minutes it funded, at the
	// full listed rate and for the time actually used.
	freeEarning := round2(float64(minutesFor(freeSeconds)) * locked.RatePerMinute)

	//------------------------------------------------
	// Debit The Customer
	//------------------------------------------------
	//
	// Skipped entirely when there is nothing to charge. A free session must
	// not lock the wallet — a concurrent recharge has no reason to contend
	// with it — and must not write a zero-amount row into wallettransaction,
	// which is the customer's money ledger and would read as a bug on their
	// transaction list. The session is still fully visible through
	// /consultation/history and the chat-history mirror.

	remaining := float64(0)

	if gross > 0 {

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

		if err != nil {
			return nil, err
		}

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

		balance := float64(0)

		if wallet.Amount != nil {
			balance = *wallet.Amount
		}

		// The cap should already prevent this, but a concurrent debit (an
		// astromall order, a report) can still land mid-session. Bill what is
		// actually there rather than pushing the wallet negative.
		if gross > balance {

			gross = round2(balance)

			if locked.RatePerMinute > 0 {
				billedMinutes = int(math.Floor(gross / locked.RatePerMinute))
			}
		}

		remaining = round2(balance - gross)

		wallet.Amount = &remaining
		wallet.UpdatedAt = &now

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

		orderRef := locked.ConsultationNo
		actor := locked.UserID

		transaction := &models.WalletTransaction{
			UserID:          locked.UserID,
			AstrologerID:    &locked.AstrologerID,
			Amount:          gross,
			IsCredit:        false,
			TransactionType: constants.MediumTransactionType(locked.Medium),
			OrderID:         &orderRef,
			CreatedBy:       &actor,
			ModifiedBy:      &actor,
			CreatedAt:       now,
			UpdatedAt:       now,
		}

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

	//------------------------------------------------
	// Platform Fee
	//------------------------------------------------
	//
	// Server-side, from systemflag. Never taken from the request body.
	//
	// The subsidy for any free minutes is netted off the commission, which is
	// what makes the fee negative on a free session: the platform is funding
	// the astrologer's earning rather than taking a cut of it. That keeps
	// gross = fee + earning true for every row in the table, so the admin
	// finance summary still sums to real platform revenue with free chat
	// accounted for rather than hidden.

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

	if feePercent < 0 {
		feePercent = 0
	}

	if feePercent > 100 {
		feePercent = 100
	}

	commission := round2(gross * feePercent / 100)

	fee := round2(commission - freeEarning)

	earning := round2(gross - commission + freeEarning)

	//------------------------------------------------
	// History Mirror
	//------------------------------------------------

	if err := s.mirrorHistory(tx, locked, now, billable, gross); err != nil {
		return nil, err
	}

	//------------------------------------------------
	// Close The Session
	//------------------------------------------------
	//
	// PENDING is the whole point: the earning is recorded as owed and waits
	// for admin review. Nothing is credited to the astrologer here.

	locked.Status = constants.ConsultationStatusCompleted
	locked.EndReason = reason
	locked.EndedAt = &now
	locked.DurationSeconds = elapsed
	locked.BilledSeconds = billable
	locked.BilledMinutes = billedMinutes
	locked.GrossAmount = gross
	locked.PlatformFeeAmount = fee
	locked.AstrologerEarning = earning
	locked.FreeMinutes = freeMinutes
	locked.FreeSeconds = freeSeconds
	locked.UpdatedAt = &now

	// A percentage of nothing says nothing. On a wholly free session the fee
	// is a flat subsidy, not a rate, so the column is left at zero rather
	// than implying a commission was applied.
	if gross > 0 {
		locked.PlatformFeePercent = feePercent
	} else {
		locked.PlatformFeePercent = 0
	}

	if earning > 0 {
		locked.SettlementStatus = constants.SettlementStatusPending
	} else {
		locked.SettlementStatus = constants.SettlementStatusNA
	}

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

	//------------------------------------------------
	// Release The Astrologer
	//------------------------------------------------
	//
	// Inside the transaction, so a rolled-back bill cannot leave an
	// astrologer stuck busy. One line here covers every way a session ends —
	// the customer hanging up, the astrologer hanging up, the cap, the stale
	// sweep and an admin force-end — because they all come through bill().

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

	s.syncQueue(tx, locked)

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

	committed = true

	//------------------------------------------------
	// The Astrologer Is Free Again
	//------------------------------------------------
	//
	// The next customer in the waiting queue gets their turn, FIFO — only
	// the head of the queue is notified, not everyone waiting. After the
	// commit so the queue is never told about a session that rolled back.

	_, _ = s.advanceQueue(locked.AstrologerID)

	return s.receipt(locked, remaining), nil
}

// waitingTypeFor maps a consultation medium onto the waiting queue's own
// vocabulary: astrologer_waiting_queue.waiting_type is an enum of CHAT and
// CALL only, so audio and video both queue as CALL.
func waitingTypeFor(medium string) string {

	if medium == constants.MediumChat {
		return "CHAT"
	}

	return "CALL"
}

// mirrorHistory writes the user_chat_histories / user_call_histories row the
// existing report endpoints and the admin panel already read, and links it
// back to the consultation.
func (s *consultationService) mirrorHistory(
	tx *gorm.DB,
	consultation *models.Consultation,
	now time.Time,
	billedSeconds int,
	gross float64,
) error {

	actor := int(consultation.UserID)

	if consultation.Medium == constants.MediumChat {

		history := &models.UserChatHistory{
			UserID:            consultation.UserID,
			AstrologerID:      consultation.AstrologerID,
			ChatStatus:        consultation.Status,
			DeductionAmount:   gross,
			ChatDuration:      billedSeconds,
			ChatType:          consultation.Medium,
			ChatHistoryNumber: consultation.ConsultationNo,
			Name:              consultation.Name,
			BirthDate:         consultation.BirthDate,
			BirthTime:         consultation.BirthTime,
			BirthPlace:        consultation.BirthPlace,
			Gender:            consultation.Gender,
			IsActive:          true,
			CreatedAt:         &now,
			UpdatedAt:         &now,
			CreatedBy:         actor,
			ModifiedBy:        actor,
		}

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

		consultation.ChatHistoryID = &history.ID

		return nil
	}

	history := &models.UserCallHistory{
		UserID:            consultation.UserID,
		AstrologerID:      consultation.AstrologerID,
		CallStatus:        consultation.Status,
		DeductionAmount:   gross,
		CallDuration:      billedSeconds,
		CallType:          consultation.Medium,
		CallHistoryNumber: consultation.ConsultationNo,
		Name:              consultation.Name,
		BirthDate:         consultation.BirthDate,
		BirthTime:         consultation.BirthTime,
		BirthPlace:        consultation.BirthPlace,
		Gender:            consultation.Gender,
		IsActive:          true,
		CreatedAt:         &now,
		UpdatedAt:         &now,
		CreatedBy:         actor,
		ModifiedBy:        actor,
	}

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

	consultation.CallHistoryID = &history.ID

	return nil
}

//////////////////////////////////////////////////////////////
// Active Session
//////////////////////////////////////////////////////////////

func (s *consultationService) GetActiveConsultation(
	userID uint,
) (*dto.ActiveConsultationResponse, error) {

	// Covers a request that is still ringing and a session that was accepted
	// but never opened, so an app killed at either point comes back to the
	// right screen rather than to an empty dashboard.
	active, err := s.repository.GetOpenConsultation(userID)

	if err != nil {
		return nil, err
	}

	if active == nil {
		return &dto.ActiveConsultationResponse{HasActive: false}, nil
	}

	//------------------------------------------------
	// Lapsed Sessions Are Not Active
	//------------------------------------------------
	//
	// A request that rang out, or an accepted session the customer never
	// opened, is closed here without billing — nothing ran, so nobody owes
	// anything and the free chat is handed back.

	if s.expireIfLapsed(active) {
		return &dto.ActiveConsultationResponse{HasActive: false}, nil
	}

	//------------------------------------------------
	// Stale Sessions Are Not Active
	//------------------------------------------------
	//
	// Bill and close it, then report that there is nothing running, so the
	// app can start fresh.
	//
	// This is the one read in the API that moves money, which is why the
	// chat timeout is four minutes rather than two: a customer who
	// backgrounded the app to check a message must not come back to a closed,
	// billed session.

	if s.isStale(active) {

		if _, err := s.bill(active, constants.EndReasonAbandoned, 0); err != nil {
			return nil, err
		}

		return &dto.ActiveConsultationResponse{HasActive: false}, nil
	}

	now := time.Now()

	elapsed := elapsedSeconds(active, now)

	balance, err := s.walletBalance(userID)

	if err != nil {
		return nil, err
	}

	response := &dto.ActiveConsultationResponse{
		HasActive:        true,
		ConsultationID:   active.ID,
		ConsultationNo:   active.ConsultationNo,
		AstrologerID:     active.AstrologerID,
		Medium:           active.Medium,
		Status:           active.Status,
		ChannelName:      active.ChannelName,
		RatePerMinute:    active.RatePerMinute,
		ElapsedSeconds:   elapsed,
		RemainingSeconds: remainingFor(active, elapsed),
		CurrentCharge:    s.currentCharge(active, elapsed),
		WalletBalance:    balance,
		IsFreeChat:       active.FreeMinutes > 0,
		FreeMinutes:      active.FreeMinutes,

		MaxBillableSeconds:  active.MaxBillableSeconds,
		TickIntervalSeconds: defaultTickIntervalSeconds,
		TickTimeoutSeconds:  s.tickTimeoutSeconds(active.Medium),
		WarningSeconds:      s.warningSeconds(),
		ServerTime:          now.Format(stampLayout),
	}

	if active.StartedAt != nil {
		response.StartedAt = active.StartedAt.Format(stampLayout)
	}

	switch active.Status {

	case constants.ConsultationStatusRequested:

		deadline := s.ringDeadline(active)

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

	case constants.ConsultationStatusAccepted:

		deadline := s.joinDeadline(active)

		response.JoinExpiresAt = deadline.Format(stampLayout)
		response.SecondsToJoinEnd = secondsUntil(deadline, now)
	}

	if astrologer, err := s.repository.GetAstrologerByID(active.AstrologerID); err == nil && astrologer != nil {

		response.AstrologerName = astrologerName(astrologer)

		// A freshly minted block, never the one issued at accept: an app
		// resuming after a kill has to renew, and handing it a stale token
		// would just fail at login.
		//
		// Withheld until the customer has actually opened the session —
		// before that they have not agreed to be charged, so there is nothing
		// for them to join yet.
		if active.StartedAt != nil {
			response.Agora = s.credentials(active, astrologer.UserID, AgoraPartyCustomer)
		}
	}

	return response, nil
}

//////////////////////////////////////////////////////////////
// History
//////////////////////////////////////////////////////////////

func (s *consultationService) GetHistory(
	userID uint,
	medium string,
	page int,
	limit int,
) (*dto.ConsultationHistoryResponse, error) {

	records, total, err := s.repository.ListUserConsultations(userID, medium, page, limit)

	if err != nil {
		return nil, err
	}

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

	// One lookup per astrologer rather than per row: a history page is
	// usually the same two or three astrologers over and over.
	names := map[uint]*models.Astrologer{}

	for _, record := range records {

		astrologer, seen := names[record.AstrologerID]

		if !seen {

			astrologer, _ = s.repository.GetAstrologerByID(record.AstrologerID)

			names[record.AstrologerID] = astrologer
		}

		item := dto.ConsultationHistoryItem{
			ConsultationID: record.ID,
			ConsultationNo: record.ConsultationNo,
			AstrologerID:   record.AstrologerID,
			Medium:         record.Medium,
			Status:         record.Status,
			Duration:       formatDuration(record.DurationSeconds),
			BilledMinutes:  record.BilledMinutes,
			RatePerMinute:  record.RatePerMinute,
			DeductedAmount: record.GrossAmount,
			ConsulteeName:  record.Name,
		}

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

		if record.CreatedAt != nil {
			item.Date = record.CreatedAt.Format("02 Jan 2006 03:04 PM")
		}

		items = append(items, item)
	}

	if page <= 0 {
		page = 1
	}

	if limit <= 0 {
		limit = 10
	}

	totalPages := 0

	if total > 0 {
		totalPages = int(math.Ceil(float64(total) / float64(limit)))
	}

	return &dto.ConsultationHistoryResponse{
		Items:      items,
		Page:       page,
		Limit:      limit,
		Total:      total,
		TotalPages: totalPages,
	}, nil
}

//////////////////////////////////////////////////////////////
// Sweeper
//////////////////////////////////////////////////////////////

// SweepStaleConsultations bills and closes every session whose ticks stopped
// arriving more than ConsultationTickTimeoutSecs ago. One bad session must not
// stop the sweep, so a failure is counted and skipped rather than returned.
func (s *consultationService) SweepStaleConsultations(limit int) (int, error) {

	timeout := s.repository.GetSystemFlagFloat(
		constants.FlagConsultationTickTimeoutSecs,
		defaultTickTimeoutSeconds,
	)

	if timeout <= 0 {
		timeout = defaultTickTimeoutSeconds
	}

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

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

	if err != nil {
		return 0, err
	}

	closed := 0

	for index := range records {

		if _, err := s.bill(&records[index], constants.EndReasonAbandoned, 0); err != nil {
			continue
		}

		closed++
	}

	return closed, nil
}

//////////////////////////////////////////////////////////////
// Helpers
//////////////////////////////////////////////////////////////

func (s *consultationService) walletBalance(userID uint) (float64, error) {

	wallet, err := s.repository.GetWallet(userID)

	if err != nil {
		return 0, err
	}

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

	return *wallet.Amount, nil
}

// isStale reports whether the app behind a session has gone quiet for longer
// than the configured tick timeout.
func (s *consultationService) isStale(consultation *models.Consultation) bool {

	// Medium-aware: a chat may go quiet for much longer before it counts as
	// abandoned, because backgrounding an app mid-chat is ordinary and
	// closing a customer's session for it would be indefensible.
	timeout := float64(s.tickTimeoutSeconds(consultation.Medium))

	if timeout <= 0 {
		timeout = defaultTickTimeoutSeconds
	}

	// A session the customer accepted but never opened has no clock and no
	// ticks; it is the join timeout's business, not the stale sweep's.
	if consultation.StartedAt == nil {
		return false
	}

	last := consultation.LastTickAt

	if last == nil {
		last = consultation.StartedAt
	}

	if last == nil {
		last = consultation.CreatedAt
	}

	if last == nil {
		return true
	}

	return time.Since(*last) > time.Duration(timeout)*time.Second
}

func (s *consultationService) receipt(
	consultation *models.Consultation,
	balance float64,
) *dto.EndConsultationResponse {

	response := &dto.EndConsultationResponse{
		ConsultationID:    consultation.ID,
		ConsultationNo:    consultation.ConsultationNo,
		Medium:            consultation.Medium,
		Status:            consultation.Status,
		EndReason:         consultation.EndReason,
		DurationSeconds:   consultation.DurationSeconds,
		Duration:          formatDuration(consultation.DurationSeconds),
		BilledMinutes:     consultation.BilledMinutes,
		RatePerMinute:     consultation.RatePerMinute,
		DeductedAmount:    consultation.GrossAmount,
		WalletBalance:     balance,
		PlatformFee:       consultation.PlatformFeeAmount,
		AstrologerEarning: consultation.AstrologerEarning,
		SettlementStatus:  consultation.SettlementStatus,
	}

	if consultation.EndedAt != nil {
		response.EndedAt = consultation.EndedAt.Format("2006-01-02 15:04:05")
	}

	// The already-billed path has no fresh balance to report, so read it.
	if balance == 0 {

		if current, err := s.walletBalance(consultation.UserID); err == nil {
			response.WalletBalance = current
		}
	}

	return response
}

func rateForMedium(astrologer *models.Astrologer, medium string) float64 {

	switch medium {

	case constants.MediumAudio:
		return float64(astrologer.AudioCallRate)

	case constants.MediumVideo:
		return float64(astrologer.VideoCallRate)

	default:

		// Older profiles filled only `charge`, so fall back to it rather than
		// refusing a chat the astrologer is listed as available for.
		if astrologer.ChatRate > 0 {
			return float64(astrologer.ChatRate)
		}

		return float64(astrologer.Charge)
	}
}

func astrologerName(astrologer *models.Astrologer) string {

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

	return astrologer.Name
}

func isLive(status string) bool {

	return status == constants.ConsultationStatusOngoing ||
		status == constants.ConsultationStatusAccepted
}

func elapsedSeconds(consultation *models.Consultation, now time.Time) int {

	start := consultation.StartedAt

	if start == nil {
		start = consultation.CreatedAt
	}

	if start == nil {
		return 0
	}

	elapsed := int(now.Sub(*start).Seconds())

	if elapsed < 0 {
		return 0
	}

	return elapsed
}

// chargeFor is what a session would cost if it stopped now — part-minutes
// round up, matching how billing settles it.
func chargeFor(seconds int, rate float64) float64 {

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

	return round2(math.Ceil(float64(seconds)/60) * rate)
}

// minutesFor rounds a stretch of talk time up to whole minutes, which is how
// the rate is quoted. Used for both the customer's charge and the platform's
// subsidy so the two sides of a part-free session round the same way.
func minutesFor(seconds int) int {

	if seconds <= 0 {
		return 0
	}

	return int(math.Ceil(float64(seconds) / 60))
}

func formatDuration(seconds int) string {

	if seconds <= 0 {
		return "00:00"
	}

	if seconds < 3600 {
		return fmt.Sprintf("%02d:%02d", seconds/60, seconds%60)
	}

	return fmt.Sprintf("%02d:%02d:%02d", seconds/3600, (seconds%3600)/60, seconds%60)
}

func consultationNumber(medium string, now time.Time, userID uint) string {

	prefix := "CHT"

	switch medium {

	case constants.MediumAudio:
		prefix = "AUD"

	case constants.MediumVideo:
		prefix = "VID"
	}

	return fmt.Sprintf("%s%s%04d", prefix, now.Format("060102150405"), userID%10000)
}

func parseDate(value string) *time.Time {

	value = strings.TrimSpace(value)

	if value == "" {
		return nil
	}

	parsed, err := time.Parse("2006-01-02", value)

	if err != nil {
		return nil
	}

	return &parsed
}

func round2(value float64) float64 {
	return math.Round(value*100) / 100
}
