package services_admin

import (
	config "astrology-api/configs"
	"astrology-api/constants"
	dto "astrology-api/dto_admin"
	usermodel "astrology-api/models/usermodel"
	"astrology-api/repositories_admin"
	"astrology-api/services"
	"errors"
	"fmt"
	"math"
	"strings"
	"time"
)

// The admin panel's window onto sessions that are still running.
//
// Named Monitor rather than ConsultationService because settlement_service.go
// in this same package already holds a field of type
// services.ConsultationService — two identically-named types in one package is
// legal Go and miserable to read.
//
// Closing a session from here goes through the user-side billing path, never a
// second implementation: an admin force-ending a session has to move the money
// exactly as the customer hanging up would.
type ConsultationMonitorService interface {
	ListLive(filter dto.LiveConsultationFilter) (*dto.LiveConsultationListResponse, error)

	LiveSummary() (*dto.LiveSummaryResponse, error)

	GetLiveDetail(id uint) (*dto.LiveConsultationDetail, error)

	ForceEnd(request dto.ForceEndRequest, adminID uint, systemCaller bool) (*dto.ForceEndResponse, error)

	GetTranscript(id uint, page int, limit int) (*dto.TranscriptResponse, error)
}

type consultationMonitorService struct {
	repository   repositories_admin.ConsultationRepository
	settlements  repositories_admin.SettlementRepository
	consultation services.ConsultationService
}

func NewConsultationMonitorService(
	repository repositories_admin.ConsultationRepository,
	settlements repositories_admin.SettlementRepository,
	consultation services.ConsultationService,
) ConsultationMonitorService {

	return &consultationMonitorService{
		repository:   repository,
		settlements:  settlements,
		consultation: consultation,
	}
}

//////////////////////////////////////////////////////////////
// List
//////////////////////////////////////////////////////////////

func (s *consultationMonitorService) ListLive(
	filter dto.LiveConsultationFilter,
) (*dto.LiveConsultationListResponse, error) {

	repoFilter := repositories_admin.LiveFilter{
		Search:       filter.Search,
		Status:       filter.Status,
		Medium:       filter.Medium,
		AstrologerID: filter.AstrologerID,
		UserID:       filter.UserID,
		StalledOnly:  filter.StalledOnly,
		FreeOnly:     filter.FreeOnly,
		SortBy:       filter.SortBy,
		SortDir:      filter.SortDir,
		Page:         filter.Page,
		Limit:        filter.Limit,
		StaleBefore:  time.Now().Add(-time.Duration(s.tickTimeout()) * time.Second),
	}

	rows, total, err := s.repository.ListLive(repoFilter)

	if err != nil {
		return nil, err
	}

	response := &dto.LiveConsultationListResponse{
		Items: make([]dto.LiveConsultationItem, 0, len(rows)),
		Page:  repoFilter.Page,
		Limit: repoFilter.Limit,
		Total: total,
	}

	now := time.Now()

	for index := range rows {

		item := s.itemFrom(&rows[index], now)

		switch item.Status {

		case constants.ConsultationStatusRequested:
			response.RequestedCount++

		case constants.ConsultationStatusAccepted:
			response.AcceptedCount++

		case constants.ConsultationStatusOngoing:
			response.OngoingCount++
		}

		if item.IsStalled {
			response.StalledCount++
		}

		response.LiveCharge += item.CurrentCharge

		response.Items = append(response.Items, item)
	}

	response.LiveCharge = round2(response.LiveCharge)

	response.TotalPages = int(total) / repoFilter.Limit

	if int(total)%repoFilter.Limit != 0 {
		response.TotalPages++
	}

	return response, nil
}

// itemFrom computes the live numbers rather than reading them.
//
// Elapsed, remaining, the running charge and staleness are all functions of
// the clock, so storing them would mean something had to keep writing to a row
// that is otherwise only touched by the tick.
func (s *consultationMonitorService) itemFrom(
	row *repositories_admin.LiveConsultationRow,
	now time.Time,
) dto.LiveConsultationItem {

	item := dto.LiveConsultationItem{
		ConsultationID:     row.ConsultationID,
		ConsultationNo:     row.ConsultationNo,
		UserID:             row.UserID,
		UserName:           row.UserName,
		UserMobile:         row.UserMobile,
		AstrologerID:       row.AstrologerID,
		AstrologerName:     row.AstrologerName,
		Medium:             row.Medium,
		Status:             row.Status,
		IsFreeChat:         row.FreeMinutes > 0,
		FreeMinutes:        row.FreeMinutes,
		ChannelName:        row.ChannelName,
		RatePerMinute:      row.RatePerMinute,
		MaxBillableSeconds: row.MaxBillableSeconds,
		RequestedAt:        stamp(row.RequestedAt),
		AcceptedAt:         stamp(row.AcceptedAt),
		StartedAt:          stamp(row.StartedAt),
		LastTickAt:         stamp(row.LastTickAt),
		CreatedAt:          stamp(row.CreatedAt),
	}

	if row.StartedAt != nil {

		elapsed := int(now.Sub(*row.StartedAt).Seconds())

		if elapsed < 0 {
			elapsed = 0
		}

		item.ElapsedSeconds = elapsed
		item.Elapsed = clock(elapsed)

		remaining := row.MaxBillableSeconds - elapsed

		if remaining < 0 {
			remaining = 0
		}

		item.RemainingSeconds = remaining

		// Only the part the customer is actually paying for. A free chat
		// still inside its allowance shows zero, which is the truth.
		paid := elapsed - row.FreeSeconds

		if paid > 0 && row.RatePerMinute > 0 {
			item.CurrentCharge = round2(math.Ceil(float64(paid)/60) * row.RatePerMinute)
		}

		last := row.LastTickAt

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

		item.SecondsSinceLastTick = int(now.Sub(*last).Seconds())
		item.IsStalled = item.SecondsSinceLastTick > s.tickTimeoutFor(row.Medium)
	}

	if count, err := s.repository.CountMessages(row.ConsultationID); err == nil {
		item.MessageCount = count
	}

	return item
}

//////////////////////////////////////////////////////////////
// Summary
//////////////////////////////////////////////////////////////

func (s *consultationMonitorService) LiveSummary() (*dto.LiveSummaryResponse, error) {

	rows, err := s.repository.LiveAggregates()

	if err != nil {
		return nil, err
	}

	response := &dto.LiveSummaryResponse{
		AgoraConfigured: config.Agora.Enabled(),
		ChatEnabled:     s.flag(constants.FlagChatConsultationEnabled, 1) == 1,
		AsOf:            time.Now().Format(stampLayout),
	}

	for _, row := range rows {

		switch row.Status {

		case constants.ConsultationStatusRequested:
			response.RequestedCount += int(row.Total)

		case constants.ConsultationStatusAccepted:
			response.AcceptedCount += int(row.Total)

		case constants.ConsultationStatusOngoing:
			response.OngoingCount += int(row.Total)

			if row.FreeMinutes > 0 {
				response.FreeChatOngoingCount += int(row.Total)
			}
		}

		switch row.Medium {

		case constants.MediumChat:
			response.ChatCount += int(row.Total)

		case constants.MediumAudio:
			response.AudioCount += int(row.Total)

		case constants.MediumVideo:
			response.VideoCount += int(row.Total)
		}
	}

	// The charge in flight needs per-row elapsed times, so it comes off the
	// list query rather than the aggregate.
	live, err := s.ListLive(dto.LiveConsultationFilter{Status: "LIVE", Page: 1, Limit: 500})

	if err == nil {

		response.LiveCharge = live.LiveCharge
		response.StalledCount = live.StalledCount

		for _, item := range live.Items {

			if item.ElapsedSeconds > response.LongestRunningSeconds {
				response.LongestRunningSeconds = item.ElapsedSeconds
			}
		}
	}

	return response, nil
}

//////////////////////////////////////////////////////////////
// Detail
//////////////////////////////////////////////////////////////

func (s *consultationMonitorService) GetLiveDetail(
	id uint,
) (*dto.LiveConsultationDetail, error) {

	row, err := s.repository.GetLiveRow(id)

	if err != nil {
		return nil, err
	}

	now := time.Now()

	detail := &dto.LiveConsultationDetail{
		LiveConsultationItem: s.itemFrom(row, now),

		UserEmail:        row.UserEmail,
		AstrologerMobile: row.AstrologerMobile,

		ConsulteeName:       row.Name,
		ConsulteeBirthTime:  row.BirthTime,
		ConsulteeBirthPlace: row.BirthPlace,
		ConsulteeGender:     row.Gender,

		RingTimeoutSeconds: int(s.flag(constants.FlagConsultationRingTimeoutSecs, 60)),
		JoinTimeoutSeconds: int(s.flag(constants.FlagConsultationJoinTimeoutSecs, 60)),
		TickTimeoutSeconds: s.tickTimeoutFor(row.Medium),

		WarnedAt:         stamp(row.WarnedAt),
		SettlementStatus: row.SettlementStatus,

		// The channel name, never the token and never the certificate. The
		// panel needs to know a channel exists, not to be able to join it.
		AgoraChannel: row.ChannelName,
	}

	if row.BirthDate != nil {
		detail.ConsulteeBirthDate = row.BirthDate.Format("2006-01-02")
	}

	if logs, err := s.settlements.GetSettlementLogs(id); err == nil {

		for _, log := range logs {

			item := dto.SettlementLogItem{
				FromStatus:   log.FromStatus,
				ToStatus:     log.ToStatus,
				Remarks:      log.Remarks,
				ActionBy:     log.ActionBy,
				ActionSource: log.ActionSource,
			}

			if log.SettlementID != nil {
				item.SettlementID = *log.SettlementID
			}

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

			detail.History = append(detail.History, item)
		}
	}

	return detail, nil
}

//////////////////////////////////////////////////////////////
// Force end
//////////////////////////////////////////////////////////////

func (s *consultationMonitorService) ForceEnd(
	request dto.ForceEndRequest,
	adminID uint,
	systemCaller bool,
) (*dto.ForceEndResponse, error) {

	if len(request.ConsultationIDs) == 0 {
		return nil, errors.New("consultation_ids is required")
	}

	remarks := strings.TrimSpace(request.Remarks)

	// Same rule as hold and reject on the settlement side: an action somebody
	// will have to justify later must carry its reason with it.
	if remarks == "" {
		return nil, errors.New("remarks are required when force-ending a consultation")
	}

	bill := true

	if request.Bill != nil {
		bill = *request.Bill
	}

	source := constants.ActionSourceAdmin

	if systemCaller {
		source = constants.ActionSourceSystem
	}

	records, err := s.repository.GetConsultationsByIDs(request.ConsultationIDs)

	if err != nil {
		return nil, err
	}

	found := make(map[uint]*usermodel.Consultation, len(records))

	for index := range records {
		found[records[index].ID] = &records[index]
	}

	response := &dto.ForceEndResponse{
		EndedIDs: make([]uint, 0, len(request.ConsultationIDs)),
		Skipped:  make([]dto.SkippedConsultation, 0),
	}

	for _, id := range request.ConsultationIDs {

		consultation, ok := found[id]

		if !ok {

			response.Skipped = append(response.Skipped, dto.SkippedConsultation{
				ConsultationID: id,
				Reason:         "consultation not found",
			})

			continue
		}

		if !constants.IsOpenStatus(consultation.Status) {

			response.Skipped = append(response.Skipped, dto.SkippedConsultation{
				ConsultationID: id,
				Reason:         fmt.Sprintf("consultation is already %s", consultation.Status),
			})

			continue
		}

		//------------------------------------------------
		// A Session That Never Started
		//------------------------------------------------
		//
		// Nothing ran, so nothing is billed whatever the request asked for.

		if consultation.StartedAt == nil || !bill {

			status := constants.ConsultationStatusCancelled
			prefix := "FORCE_CANCEL"

			if consultation.Status == constants.ConsultationStatusRequested {
				status = constants.ConsultationStatusMissed
			}

			if consultation.StartedAt != nil {
				prefix = "FORCE_END_UNBILLED"
			}

			moved, err := s.repository.CloseWithoutBilling(
				id,
				status,
				constants.EndReasonAdminEnded,
				remarks,
				adminID,
			)

			if err != nil || !moved {

				response.Skipped = append(response.Skipped, dto.SkippedConsultation{
					ConsultationID: id,
					Reason:         "consultation changed while it was being closed",
				})

				continue
			}

			// The log columns carry SETTLEMENT statuses, because that is what
			// every existing reader of this table renders. The session
			// transition goes in the remarks behind a prefix rather than
			// corrupting those columns.
			_ = s.repository.AppendLog(
				id,
				constants.SettlementStatusNA,
				constants.SettlementStatusNA,
				prefix+": "+remarks,
				adminID,
				source,
			)

			response.EndedCount++
			response.EndedIDs = append(response.EndedIDs, id)

			continue
		}

		//------------------------------------------------
		// A Running Session
		//------------------------------------------------
		//
		// Billed through the user-side path, exactly as if the astrologer had
		// hung up. Duplicating the billing logic here is how the two drift.

		receipt, err := s.consultation.EndConsultationByAdmin(id)

		if err != nil {

			response.Skipped = append(response.Skipped, dto.SkippedConsultation{
				ConsultationID: id,
				Reason:         err.Error(),
			})

			continue
		}

		_ = s.repository.StampAdminAction(id, remarks, adminID)

		_ = s.repository.AppendLog(
			id,
			constants.SettlementStatusNA,
			receipt.SettlementStatus,
			"FORCE_END: "+remarks,
			adminID,
			source,
		)

		response.EndedCount++
		response.EndedIDs = append(response.EndedIDs, id)
		response.TotalBilled = round2(response.TotalBilled + receipt.DeductedAmount)
		response.TotalEarning = round2(response.TotalEarning + receipt.AstrologerEarning)
	}

	return response, nil
}

//////////////////////////////////////////////////////////////
// Transcript
//////////////////////////////////////////////////////////////

func (s *consultationMonitorService) GetTranscript(
	id uint,
	page int,
	limit int,
) (*dto.TranscriptResponse, error) {

	row, err := s.repository.GetLiveRow(id)

	if err != nil {
		return nil, err
	}

	rows, total, err := s.repository.ListMessages(id, page, limit)

	if err != nil {
		return nil, err
	}

	if page <= 0 {
		page = 1
	}

	if limit <= 0 || limit > 1000 {
		limit = 200
	}

	response := &dto.TranscriptResponse{
		ConsultationID: row.ConsultationID,
		ConsultationNo: row.ConsultationNo,
		Medium:         row.Medium,
		UserID:         row.UserID,
		UserName:       row.UserName,
		AstrologerID:   row.AstrologerID,
		AstrologerName: row.AstrologerName,
		StartedAt:      stamp(row.StartedAt),
		EndedAt:        "",
		MessageCount:   total,
		Total:          total,
		Page:           page,
		Limit:          limit,
		Messages:       make([]dto.TranscriptMessage, 0, len(rows)),
	}

	if row.Medium != constants.MediumChat {

		response.UploadStatus = "NOT_APPLICABLE"

		return response, nil
	}

	uploaders, _ := s.repository.TranscriptUploaders(id)

	response.UploadedBy = uploaders

	// An empty transcript is information, not an error: it means neither app
	// managed to flush its buffer before it was killed.
	switch len(uploaders) {

	case 0:
		response.UploadStatus = "NONE"

	case 1:
		response.UploadStatus = "PARTIAL"

	default:
		response.UploadStatus = "COMPLETE"
	}

	for _, message := range rows {

		name := row.UserName

		if message.SenderType == constants.SenderTypeAstrologer {
			name = row.AstrologerName
		}

		response.Messages = append(response.Messages, dto.TranscriptMessage{
			Sequence:        message.Sequence,
			ClientMessageID: message.ClientMessageID,
			SenderType:      message.SenderType,
			SenderID:        message.SenderID,
			SenderName:      name,
			MessageType:     message.MessageType,
			Body:            message.Body,
			SentAt:          stamp(message.SentAt),
			UploadedBy:      message.UploadedBy,
		})
	}

	response.TotalPages = int(total) / limit

	if int(total)%limit != 0 {
		response.TotalPages++
	}

	return response, nil
}

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

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

func (s *consultationMonitorService) flag(name string, fallback float64) float64 {

	values, err := s.settlements.GetSystemFlags([]string{name})

	if err != nil {
		return fallback
	}

	return flagFloat(values, name, fallback)
}

func (s *consultationMonitorService) tickTimeout() int {

	return int(s.flag(constants.FlagConsultationTickTimeoutSecs, 120))
}

func (s *consultationMonitorService) tickTimeoutFor(medium string) int {

	if medium == constants.MediumChat {

		if chat := int(s.flag(constants.FlagChatTickTimeoutSecs, 240)); chat > 0 {
			return chat
		}
	}

	return s.tickTimeout()
}

func stamp(value *time.Time) string {

	if value == nil {
		return ""
	}

	return value.Format(stampLayout)
}

func clock(seconds int) string {

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

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

