package services

import (
	"log"
	"strings"
	"sync"
	"time"

	configs "astrology-api/configs"
	usermodel "astrology-api/models/usermodel"
)

//////////////////////////////////////////////////////////////
// Astrology Service Catalogue and Usage
//////////////////////////////////////////////////////////////
//
// The admin panel owns `astrology_services` (one row per astrology service,
// keyed by a stable `code`) and reports on `astrology_service_usage` at
// Astrology Services > Service Usage. The contract is
// astrology-admin/docs/api-changes/astrology-services-api-changes.txt; this
// file implements its sections 1 (the on/off switches) and 2 (usage rows);
// astrology_pricing.go implements section 3 (free quota and price).
//
// middleware.TrackAstrologyService is the only caller. It asks AllowAstrologyService before
// the handler and hands RecordAstrologyUsage the outcome after.

// usage.actor_type
const (
	UsageActorCustomer   = "CUSTOMER"
	UsageActorAstrologer = "ASTROLOGER"
	UsageActorGuest      = "GUEST"
)

// ServiceUnavailableMessage is what a caller sees for a service the admin has
// switched off.
const ServiceUnavailableMessage = "This service is currently unavailable."

const catalogueTTL = 60 * time.Second

type astrologyCatalogue struct {
	mu       sync.RWMutex
	byCode   map[string]usermodel.AstrologyService
	loadedAt time.Time
	loadErr  error
}

var catalogue = &astrologyCatalogue{}

// get returns a row, refreshing the whole table at most once per catalogueTTL. Forty-seven
// rows is nothing; a query per request would be a needless round trip. On a
// failed read the previous copy is kept, and with no copy at all every
// service is allowed (fail open - see AllowAstrologyService).
func (c *astrologyCatalogue) get(code string) (usermodel.AstrologyService, bool) {

	c.mu.RLock()
	fresh := time.Since(c.loadedAt) < catalogueTTL
	row, ok := c.byCode[code]
	c.mu.RUnlock()

	if fresh {
		return row, ok
	}

	c.mu.Lock()
	defer c.mu.Unlock()

	// Another request may have refreshed it while this one waited.
	if time.Since(c.loadedAt) >= catalogueTTL {
		c.refresh()
	}

	row, ok = c.byCode[code]

	return row, ok
}

func (c *astrologyCatalogue) refresh() {

	// Stamp first, so a failing database is retried once a minute rather
	// than on every request.
	c.loadedAt = time.Now()

	if configs.DB == nil {
		return
	}

	var rows []usermodel.AstrologyService

	if err := configs.DB.Find(&rows).Error; err != nil {

		if c.loadErr == nil {
			log.Printf("astrology usage: cannot read astrology_services, serving every service: %v", err)
		}

		c.loadErr = err

		return
	}

	byCode := make(map[string]usermodel.AstrologyService, len(rows))

	for _, row := range rows {
		byCode[row.Code] = row
	}

	c.byCode = byCode
	c.loadErr = nil
}

// AllowAstrologyService reports whether the admin lets this actor use the service, and the
// catalogue row when there is one.
//
// Fail open: an unreadable table or a code with no row serves the request. A
// missing settings row must not take kundali down for everybody; the cost of
// failing open is one service staying reachable a little longer.
func AllowAstrologyService(code string, actor string) (usermodel.AstrologyService, bool, bool) {

	row, found := catalogue.get(code)

	if !found {
		return row, false, true
	}

	if !row.IsActive || strings.EqualFold(row.AccessMode, "DISABLED") {
		return row, true, false
	}

	if actor == UsageActorAstrologer {
		return row, true, row.AstrologerEnabled
	}

	// Customers and guests are both the customer audience.
	return row, true, row.CustomerEnabled
}

//////////////////////////////////////////////////////////////
// Usage Rows
//////////////////////////////////////////////////////////////

// UsageEvent is the outcome of one call, filled in by the middleware.
type UsageEvent struct {
	Code          string
	Service       usermodel.AstrologyService
	Found         bool
	UserID        uint
	AstrologerID  uint
	ActorType     string
	Platform      string
	DeviceType    string
	AppVersion    string
	ChargedAmount float64
	Succeeded     bool
	FailureReason string
	Duration      time.Duration
}

var unknownCodes sync.Map

// RecordAstrologyUsage writes one astrology_service_usage row on a goroutine.
// It never fails the request: a lost row is a reporting gap, a failed call is
// an outage.
func RecordAstrologyUsage(event UsageEvent) {

	// service_id is NOT NULL, so a code missing from the catalogue cannot be
	// recorded. Log it once per code so a typo in a route is noticed.
	if !event.Found {

		if _, seen := unknownCodes.LoadOrStore(event.Code, true); !seen {
			log.Printf("astrology usage: %q is not in astrology_services, usage not recorded", event.Code)
		}

		return
	}

	row := usermodel.AstrologyServiceUsage{
		ServiceID:     event.Service.ID,
		ServiceCode:   event.Code,
		ActorType:     event.ActorType,
		Platform:      optionalStr(event.Platform),
		DeviceType:    optionalStr(event.DeviceType),
		AppVersion:    optionalStr(event.AppVersion),
		Provider:      optionalStr(event.Service.Provider),
		WasFree:       event.ChargedAmount <= 0,
		ChargedAmount: event.ChargedAmount,
		// Nothing is cached yet; the admin's cache_minutes is not enforced.
		WasCached: false,
		Succeeded: event.Succeeded,
		CreatedAt: time.Now(),
	}

	if event.UserID > 0 {
		userID := event.UserID
		row.UserID = &userID
	}

	if event.AstrologerID > 0 {
		astrologerID := event.AstrologerID
		row.AstrologerID = &astrologerID
	}

	ms := event.Duration.Milliseconds()
	row.ResponseMs = &ms

	if !event.Succeeded && event.FailureReason != "" {

		reason := event.FailureReason

		if len(reason) > 255 {
			reason = reason[:255]
		}

		row.FailureReason = &reason
	}

	go func() {

		defer func() {
			if r := recover(); r != nil {
				log.Printf("astrology usage: insert panicked: %v", r)
			}
		}()

		if err := configs.DB.Omit(missingUsageColumns()...).Create(&row).Error; err != nil {
			log.Printf("astrology usage: insert failed for %s: %v", event.Code, err)
		}
	}()
}

// AstrologerIDForUser maps an astrologer token's users.id to astrologers.id,
// which is what astrology_service_usage.astrologer_id holds. 0 when unknown.
func AstrologerIDForUser(userID uint) uint {

	if configs.DB == nil || userID == 0 {
		return 0
	}

	var id uint

	configs.DB.
		Table("astrologers").
		Select("id").
		Where("userId = ?", userID).
		Order("id ASC").
		Limit(1).
		Scan(&id)

	return id
}

var (
	usageColumnsOnce sync.Once
	usageColumnsGone []string
)

// missingUsageColumns lists the client columns this database does not have
// yet. They come from migrations/2026_09_25_astrology_service_usage_platform.sql;
// until it has run, rows are still written - just without platform - rather
// than every insert failing on an unknown column.
func missingUsageColumns() []string {

	usageColumnsOnce.Do(func() {

		for _, column := range []string{"platform", "device_type", "app_version"} {

			if !configs.DB.Migrator().HasColumn(&usermodel.AstrologyServiceUsage{}, column) {
				usageColumnsGone = append(usageColumnsGone, column)
			}
		}

		if len(usageColumnsGone) > 0 {
			log.Printf("astrology usage: %v missing, run migrations/2026_09_25_astrology_service_usage_platform.sql", usageColumnsGone)
		}
	})

	return usageColumnsGone
}
