package repositories

import (
	"astrology-api/constants"
	models "astrology-api/models/usermodel"
	"errors"
	"strconv"
	"strings"
	"time"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

// ConsultationRepository is the data layer behind a customer's chat / audio /
// video session: starting one, keeping it alive, and the billing writes when
// it ends.
//
// Billing touches four tables at once — the consultation, the customer wallet,
// wallettransaction and the chat/call history mirror — so the write side takes
// a *gorm.DB and runs inside the service's transaction.
type ConsultationRepository interface {

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

	// GetAstrologerByID reads the astrologer the customer picked, including
	// the per-minute rates the session will be billed at.
	GetAstrologerByID(astrologerID uint) (*models.Astrologer, error)

	// GetAstrologerByUserID maps an astrologer's login to their astrologers
	// row, so the astrologer app can end a session it is a party to.
	GetAstrologerByUserID(userID uint) (*models.Astrologer, error)

	//------------------------------------------------
	// Consultation
	//------------------------------------------------

	CreateConsultation(consultation *models.Consultation) error

	GetConsultationByID(id uint) (*models.Consultation, error)

	// GetConsultationForUpdate re-reads the row inside the billing
	// transaction with SELECT ... FOR UPDATE. Two clients can both call end
	// (the customer hangs up while the tick also decides to disconnect), and
	// the lock plus the status check is what stops the session being billed
	// twice.
	GetConsultationForUpdate(tx *gorm.DB, id uint) (*models.Consultation, error)

	// GetActiveConsultation returns the customer's open session, or nil. Used
	// to stop a second session starting and to let a killed app rejoin.
	GetActiveConsultation(userID uint) (*models.Consultation, error)

	// TouchTick records that the app is still there. Deliberately a narrow
	// UPDATE rather than a Save of the whole row: the tick runs every few
	// seconds and must not write anything the billing path owns.
	TouchTick(id uint, at time.Time) error

	UpdateConsultation(tx *gorm.DB, consultation *models.Consultation) error

	// GetStaleOngoing returns sessions whose ticks stopped arriving before
	// `before`. The sweeper closes and bills them.
	GetStaleOngoing(before time.Time, limit int) ([]models.Consultation, error)

	ListUserConsultations(
		userID uint,
		medium string,
		page int,
		limit int,
	) ([]models.Consultation, int64, error)

	//------------------------------------------------
	// Wallet
	//------------------------------------------------

	GetWallet(userID uint) (*models.UserWallet, error)

	// GetWalletForUpdate locks the wallet row for the duration of the billing
	// transaction, so a recharge landing at the same moment cannot be lost.
	GetWalletForUpdate(tx *gorm.DB, userID uint) (*models.UserWallet, error)

	UpdateWallet(tx *gorm.DB, wallet *models.UserWallet) error

	CreateWalletTransaction(tx *gorm.DB, transaction *models.WalletTransaction) error

	//------------------------------------------------
	// History mirror
	//------------------------------------------------
	//
	// user_chat_histories / user_call_histories keep being written alongside
	// consultations, so /report/chat-history, /report/call-history and
	// whatever the admin panel already reads stay correct.

	CreateChatHistory(tx *gorm.DB, history *models.UserChatHistory) error

	CreateCallHistory(tx *gorm.DB, history *models.UserCallHistory) error

	//------------------------------------------------
	// Configuration
	//------------------------------------------------

	// GetSystemFlagFloat reads a numeric systemflag. A missing or unparsable
	// row yields the fallback rather than an error, so the flow keeps working
	// before the configuration migration has been applied.
	GetSystemFlagFloat(name string, fallback float64) float64

	//------------------------------------------------
	// Availability
	//------------------------------------------------
	//
	// The user stack has never read astrologer_status, which is why a
	// customer could start a paid session with an astrologer who was
	// offline. These read it as a narrow projection rather than importing the
	// astrologer stack's model — the two stacks do not share layers.

	GetAstrologerAvailability(astrologerID uint) (*AstrologerAvailability, error)

	// SetAstrologerBusy joins the caller's transaction, which the astrologer
	// stack's own UpdateBusyStatus cannot: it reaches for the global handle.
	// Set inside accept, cleared inside bill() — one line there covers every
	// close path, including the sweeper's.
	SetAstrologerBusy(tx *gorm.DB, astrologerID uint, busy bool) error

	// ReconcileAstrologerBusy clears the flag for anyone who is marked busy
	// with no open session. The only thing that can un-wedge an astrologer
	// stuck busy by a crash or a manual edit.
	ReconcileAstrologerBusy() (int64, error)

	// CountOpenForAstrologer is the authoritative busy test: it reads the
	// same table the flow writes, so unlike the flag it cannot drift.
	CountOpenForAstrologer(astrologerID uint) (int64, error)

	// LockOpenForAstrologer claims the astrologer's open rows inside the
	// accept transaction, so two simultaneous accepts cannot both win.
	LockOpenForAstrologer(tx *gorm.DB, astrologerID uint) ([]models.Consultation, error)

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

	// LockAstrologerQueue serialises every queue transition for one
	// astrologer by locking their astrologers row inside tx.
	LockAstrologerQueue(tx *gorm.DB, astrologerID uint) error

	ListQueueForAstrologer(tx *gorm.DB, astrologerID uint) ([]models.AstrologerWaitingQueue, error)

	FindActiveQueueEntry(
		tx *gorm.DB,
		userID uint,
		astrologerID uint,
		waitingType string,
	) (*models.AstrologerWaitingQueue, error)

	CreateQueueEntry(tx *gorm.DB, row *models.AstrologerWaitingQueue) error

	GetQueueEntry(id uint) (*models.AstrologerWaitingQueue, error)

	UpdateQueueEntry(tx *gorm.DB, id uint, updates map[string]interface{}) error

	ListUserQueueEntries(userID uint) ([]models.AstrologerWaitingQueue, error)

	ListAstrologersWithQueue(limit int) ([]uint, error)

	CloseQueueForConsultation(
		tx *gorm.DB,
		consultationID uint,
		from []string,
		updates map[string]interface{},
	) error

	//------------------------------------------------
	// The handshake
	//------------------------------------------------

	// GetOpenConsultation covers REQUESTED, ACCEPTED and ONGOING.
	//
	// GetActiveConsultation deliberately stays ACCEPTED+ONGOING: the billing
	// and stale paths must never be handed a row that was never started. This
	// is the separate lookup that stops a customer firing requests at several
	// astrologers at once.
	GetOpenConsultation(userID uint) (*models.Consultation, error)

	ListAstrologerRequests(astrologerID uint, limit int) ([]models.Consultation, error)

	GetExpiredRequested(before time.Time, limit int) ([]models.Consultation, error)

	GetUnjoinedAccepted(before time.Time, limit int) ([]models.Consultation, error)

	// SetChannelName is called straight after the insert. The channel is
	// derived from the row id rather than from consultationNo, which can
	// collide — two strangers must never share an Agora channel.
	SetChannelName(id uint, channelName string) error

	//------------------------------------------------
	// The server-authoritative timer
	//------------------------------------------------

	GetOverrunConsultations(now time.Time, limit int) ([]models.Consultation, error)

	GetPendingWarnings(now time.Time, warnSeconds int, limit int) ([]models.Consultation, error)

	// ClaimWarning is a conditional UPDATE. It reports whether this caller is
	// the one that got to send the warning, which is what keeps it
	// exactly-once across the tick and the sweep running concurrently.
	ClaimWarning(id uint, at time.Time) (bool, error)

	//------------------------------------------------
	// Free chat
	//------------------------------------------------

	// HasUsedFreeChat answers the lifetime entitlement from the sessions
	// themselves, so there is no second source of truth to drift.
	HasUsedFreeChat(userID uint, minConnectedSeconds int) (bool, error)

	//------------------------------------------------
	// Transcript
	//------------------------------------------------

	ExistingClientMessageIDs(consultationID uint, ids []string) (map[string]bool, error)

	CreateChatMessages(tx *gorm.DB, messages []models.ChatMessage) error

	CountChatMessages(consultationID uint) (int64, error)

	ListChatMessages(consultationID uint, page int, limit int) ([]models.ChatMessage, int64, error)

	//------------------------------------------------
	// Misc
	//------------------------------------------------

	GetUserByID(userID uint) (*models.User, error)

	//------------------------------------------------
	// Transaction
	//------------------------------------------------

	Begin() *gorm.DB

	DB() *gorm.DB
}

// AstrologerAvailability is astrologer_status as the customer's side needs to
// see it.
//
// Read with a raw table select rather than by importing the astrologer
// stack's model: the two stacks are separate by design and this is the one
// fact the customer side has to borrow.
//
// current_status is deliberately absent. The dashboard writes "OFFLINE" into
// an enum('offline','online','busy','dnd'), which under a non-strict SQL mode
// silently stores the empty string — so the booleans are the only trustworthy
// columns here.
type AstrologerAvailability struct {
	Found bool `gorm:"-"`

	IsOnline     bool `gorm:"column:is_online"`
	IsChatOnline bool `gorm:"column:is_chat_online"`
	IsCallOnline bool `gorm:"column:is_call_online"`
	IsBusy       bool `gorm:"column:is_busy"`
	QuickDND     bool `gorm:"column:quick_dnd"`
}

type consultationRepository struct {
	db *gorm.DB
}

func NewConsultationRepository(db *gorm.DB) ConsultationRepository {
	return &consultationRepository{
		db: db,
	}
}

//////////////////////////////////////////////////////////////
// Astrologer
//////////////////////////////////////////////////////////////

func (r *consultationRepository) GetAstrologerByID(
	astrologerID uint,
) (*models.Astrologer, error) {

	var astrologer models.Astrologer

	err := r.db.
		Where("id = ?", astrologerID).
		Where("isDelete = ?", false).
		First(&astrologer).Error

	if err != nil {

		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}

		return nil, err
	}

	return &astrologer, nil
}

func (r *consultationRepository) GetAstrologerByUserID(
	userID uint,
) (*models.Astrologer, error) {

	var astrologer models.Astrologer

	err := r.db.
		Where("userId = ?", userID).
		Where("isDelete = ?", false).
		First(&astrologer).Error

	if err != nil {

		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}

		return nil, err
	}

	return &astrologer, nil
}

//////////////////////////////////////////////////////////////
// Consultation
//////////////////////////////////////////////////////////////

func (r *consultationRepository) CreateConsultation(
	consultation *models.Consultation,
) error {

	return r.db.Create(consultation).Error
}

func (r *consultationRepository) GetConsultationByID(
	id uint,
) (*models.Consultation, error) {

	var consultation models.Consultation

	err := r.db.
		Where("id = ?", id).
		Where("isDelete = ?", false).
		First(&consultation).Error

	if err != nil {

		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}

		return nil, err
	}

	return &consultation, nil
}

func (r *consultationRepository) GetConsultationForUpdate(
	tx *gorm.DB,
	id uint,
) (*models.Consultation, error) {

	db := tx

	if db == nil {
		db = r.db
	}

	var consultation models.Consultation

	err := db.
		Clauses(clause.Locking{Strength: "UPDATE"}).
		Where("id = ?", id).
		Where("isDelete = ?", false).
		First(&consultation).Error

	if err != nil {

		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}

		return nil, err
	}

	return &consultation, nil
}

func (r *consultationRepository) GetActiveConsultation(
	userID uint,
) (*models.Consultation, error) {

	var consultation models.Consultation

	err := r.db.
		Where("userId = ?", userID).
		Where("status IN ?", []string{
			constants.ConsultationStatusAccepted,
			constants.ConsultationStatusOngoing,
		}).
		Where("isDelete = ?", false).
		Order("id DESC").
		First(&consultation).Error

	if err != nil {

		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}

		return nil, err
	}

	return &consultation, nil
}

func (r *consultationRepository) TouchTick(
	id uint,
	at time.Time,
) error {

	return r.db.
		Model(&models.Consultation{}).
		Where("id = ?", id).
		Updates(map[string]interface{}{
			"lastTickAt": at,
			"updated_at": at,
		}).Error
}

func (r *consultationRepository) UpdateConsultation(
	tx *gorm.DB,
	consultation *models.Consultation,
) error {

	db := tx

	if db == nil {
		db = r.db
	}

	return db.Save(consultation).Error
}

func (r *consultationRepository) GetStaleOngoing(
	before time.Time,
	limit int,
) ([]models.Consultation, error) {

	var records []models.Consultation

	if limit <= 0 {
		limit = 100
	}

	// COALESCE: a session that never ticked at all falls back to when it
	// started, so it is still swept rather than hanging open forever.
	err := r.db.
		Where("status IN ?", []string{
			constants.ConsultationStatusAccepted,
			constants.ConsultationStatusOngoing,
		}).
		Where("isDelete = ?", false).
		Where("COALESCE(lastTickAt, startedAt, created_at) < ?", before).
		Order("id ASC").
		Limit(limit).
		Find(&records).Error

	if err != nil {
		return nil, err
	}

	return records, nil
}

func (r *consultationRepository) ListUserConsultations(
	userID uint,
	medium string,
	page int,
	limit int,
) ([]models.Consultation, int64, error) {

	var records []models.Consultation
	var total int64

	query := r.db.
		Model(&models.Consultation{}).
		Where("userId = ?", userID).
		Where("isDelete = ?", false)

	if medium != "" && strings.ToUpper(medium) != "ALL" {
		query = query.Where("medium = ?", strings.ToUpper(medium))
	}

	if err := query.Count(&total).Error; err != nil {
		return nil, 0, err
	}

	if page <= 0 {
		page = 1
	}

	if limit <= 0 {
		limit = 10
	}

	err := query.
		Order("id DESC").
		Offset((page - 1) * limit).
		Limit(limit).
		Find(&records).Error

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

	return records, total, nil
}

//////////////////////////////////////////////////////////////
// Wallet
//////////////////////////////////////////////////////////////

func (r *consultationRepository) GetWallet(
	userID uint,
) (*models.UserWallet, error) {

	var wallet models.UserWallet

	err := r.db.
		Where("userId = ?", userID).
		Where("isDelete = ?", false).
		First(&wallet).Error

	if err != nil {

		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}

		return nil, err
	}

	return &wallet, nil
}

func (r *consultationRepository) GetWalletForUpdate(
	tx *gorm.DB,
	userID uint,
) (*models.UserWallet, error) {

	db := tx

	if db == nil {
		db = r.db
	}

	var wallet models.UserWallet

	err := db.
		Clauses(clause.Locking{Strength: "UPDATE"}).
		Where("userId = ?", userID).
		Where("isDelete = ?", false).
		First(&wallet).Error

	if err != nil {

		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}

		return nil, err
	}

	return &wallet, nil
}

func (r *consultationRepository) UpdateWallet(
	tx *gorm.DB,
	wallet *models.UserWallet,
) error {

	db := tx

	if db == nil {
		db = r.db
	}

	return db.Save(wallet).Error
}

func (r *consultationRepository) CreateWalletTransaction(
	tx *gorm.DB,
	transaction *models.WalletTransaction,
) error {

	db := tx

	if db == nil {
		db = r.db
	}

	return db.Create(transaction).Error
}

//////////////////////////////////////////////////////////////
// History mirror
//////////////////////////////////////////////////////////////

func (r *consultationRepository) CreateChatHistory(
	tx *gorm.DB,
	history *models.UserChatHistory,
) error {

	db := tx

	if db == nil {
		db = r.db
	}

	return db.Create(history).Error
}

func (r *consultationRepository) CreateCallHistory(
	tx *gorm.DB,
	history *models.UserCallHistory,
) error {

	db := tx

	if db == nil {
		db = r.db
	}

	return db.Create(history).Error
}

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

func (r *consultationRepository) GetSystemFlagFloat(
	name string,
	fallback float64,
) float64 {

	var flag models.SystemFlag

	err := r.db.
		Where("name = ?", name).
		Where("isActive = ?", true).
		Where("isDelete = ?", false).
		First(&flag).Error

	if err != nil {
		return fallback
	}

	value, err := strconv.ParseFloat(strings.TrimSpace(flag.Value), 64)

	if err != nil {
		return fallback
	}

	return value
}

//////////////////////////////////////////////////////////////
// Transaction
//////////////////////////////////////////////////////////////

func (r *consultationRepository) Begin() *gorm.DB {
	return r.db.Begin()
}

func (r *consultationRepository) DB() *gorm.DB {
	return r.db
}
