package assignment

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"doormile/constants"
	"doormile/db"
	"doormile/models"
	"doormile/utils"

	"github.com/redis/go-redis/v9"
)

const (
	maxRetries  = 5
	retryDelay  = 2 * time.Minute
	geoRadiusKm = 10.0
	geoMaxCount = 10
	maxActive   = 3
)

type milerCandidate struct {
	profile        models.MilerProfile
	distanceKm     float64
	activeBookings int64
}

// AssignCRMMiler finds the best available nearby miler for a CRM booking and assigns them.
// It retries up to maxRetries times (retryDelay apart) before logging NO_MILER_AVAILABLE.
// Must be called as a goroutine after tx.Commit() in CreateCRMBooking.
func AssignCRMMiler(bookingID int) {
	defer func() {
		if r := recover(); r != nil {
			utils.Error("CRMAssignment: panic recovered", "booking_id", bookingID, "error", r)
		}
	}()

	for attempt := 1; attempt <= maxRetries; attempt++ {
		if attempt > 1 {
			time.Sleep(retryDelay)
		}

		utils.Info("CRMAssignment: attempting assignment", "booking_id", bookingID, "attempt", attempt)

		done, err := tryAssign(bookingID)
		if err != nil {
			utils.Error("CRMAssignment: attempt error", "booking_id", bookingID, "attempt", attempt, "error", err)
			continue
		}
		if done {
			return
		}

		utils.Warn("CRMAssignment: no eligible miler found on attempt",
			"booking_id", bookingID,
			"attempt", attempt,
			"remaining", maxRetries-attempt,
		)
	}

	utils.Error("CRMAssignment: NO_MILER_AVAILABLE — all retries exhausted",
		"booking_id", bookingID,
		"max_retries", maxRetries,
	)
}

// tryAssign performs a single attempt: queries Redis GEO, scores candidates, commits.
// Returns (true, nil) on success or when the booking no longer needs assignment.
// Returns (false, nil) when no eligible miler was found (retry warranted).
// Returns (false, err) on hard errors (booking missing, DB failure).
func tryAssign(bookingID int) (bool, error) {
	var booking models.PickupBooking
	if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil {
		return false, fmt.Errorf("load booking: %w", err)
	}

	// If the booking was cancelled or already assigned between retries, stop.
	if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil {
		utils.Info("CRMAssignment: booking no longer needs assignment",
			"booking_id", bookingID,
			"status", booking.Status,
		)
		return true, nil
	}

	if booking.Pickuplatitude == 0 || booking.Pickuplongitude == 0 {
		return false, fmt.Errorf("booking %d has no pickup coordinates", bookingID)
	}

	nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude)
	if err != nil {
		utils.Warn("CRMAssignment: GEO query failed", "booking_id", bookingID, "error", err)
		return false, nil
	}
	if len(nearby) == 0 {
		return false, nil
	}

	candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
	if !found {
		return false, nil
	}

	if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil {
		return false, fmt.Errorf("commit: %w", err)
	}

	return true, nil
}

// AutoAssignResult carries enough detail for a synchronous caller (the hub
// console's manual "auto-assign" trigger) to report what happened, since that
// caller can't rely on the fire-and-forget logging AssignCRMMiler normally uses.
type AutoAssignResult struct {
	Assigned         bool
	Escalated        bool
	MilerUserID      int
	MilerName        string
	DistanceKm       float64
	Reasoning        string
	SearchedRadiusKm float64
	CandidatesFound  int
}

// TryAssignOnce performs a single assignment attempt (GEOSEARCH → AI decision
// → commit) with no retry loop, for a booking that wasn't auto-assigned at
// creation time. AssignCRMMiler/AssignCustomerMiler retry over ~10 minutes,
// which is too slow for a hub staff member waiting on a synchronous response;
// this wraps the same single-attempt core (tryAssign) used internally by both.
func TryAssignOnce(bookingID int) (AutoAssignResult, error) {
	var booking models.PickupBooking
	if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil {
		return AutoAssignResult{}, fmt.Errorf("load booking: %w", err)
	}

	if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil {
		return AutoAssignResult{Assigned: true}, nil
	}

	if booking.Pickuplatitude == 0 || booking.Pickuplongitude == 0 {
		return AutoAssignResult{}, fmt.Errorf("booking %d has no pickup coordinates", bookingID)
	}

	nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude)
	if err != nil {
		return AutoAssignResult{Escalated: true, Reasoning: "miler location search unavailable", SearchedRadiusKm: geoRadiusKm}, nil
	}
	if len(nearby) == 0 {
		return AutoAssignResult{Escalated: true, Reasoning: "no milers within search radius", SearchedRadiusKm: geoRadiusKm}, nil
	}

	candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
	if !found {
		return AutoAssignResult{
			Escalated:        true,
			Reasoning:        "no eligible miler after evaluation",
			SearchedRadiusKm: geoRadiusKm,
			CandidatesFound:  len(nearby),
		}, nil
	}

	if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil {
		return AutoAssignResult{}, fmt.Errorf("commit: %w", err)
	}

	reasoning := ""
	if agentDecisionID != nil {
		var ad models.AgentDecision
		if db.DB.Where("id = ?", *agentDecisionID).First(&ad).Error == nil {
			reasoning = ad.Reasoning
		}
	}

	return AutoAssignResult{
		Assigned:        true,
		MilerUserID:     candidate.profile.Userid,
		MilerName:       candidate.profile.Displayname,
		DistanceKm:      candidate.distanceKm,
		Reasoning:       reasoning,
		CandidatesFound: len(nearby),
	}, nil
}

// queryNearbyMilers runs GEOSEARCH on milers:locations and returns up to geoMaxCount
// milers within geoRadiusKm km, sorted nearest-first, with distances populated.
func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) {
	if db.Rdb == nil {
		return nil, fmt.Errorf("Redis not available")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
		GeoSearchQuery: redis.GeoSearchQuery{
			Longitude:  lon,
			Latitude:   lat,
			Radius:     geoRadiusKm,
			RadiusUnit: "km",
			Sort:       "ASC",
			Count:      geoMaxCount,
		},
		WithDist: true,
	}).Result()
	if err != nil {
		return nil, err
	}

	return locs, nil
}

// commitAssignment writes the BookingAssignment row, updates the booking and the
// miler's availability status in a single transaction, then publishes to NATS.
func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, agentDecisionID *uint64) error {
	milerUserID := candidate.profile.Userid

	tx := db.DB.Begin()

	assignment := models.BookingAssignment{
		Bookingid:        booking.Bookingid,
		Mileruserid:      milerUserID,
		Assignmentstatus: constants.AssignmentAssigned,
		Assignedat:       time.Now(),
		AgentDecisionID:  agentDecisionID,
	}
	if err := tx.Create(&assignment).Error; err != nil {
		tx.Rollback()
		return fmt.Errorf("create BookingAssignment: %w", err)
	}

	now := time.Now()
	if err := tx.Model(booking).Updates(map[string]interface{}{
		"assignedmileruserid": milerUserID,
		"status":              constants.BookingMilerAssigned,
		"updatedat":           now,
	}).Error; err != nil {
		tx.Rollback()
		return fmt.Errorf("update PickupBooking: %w", err)
	}

	if err := tx.Model(&models.MilerProfile{}).
		Where("userid = ?", milerUserID).
		Update("availabilitystatus", constants.MilerAssigned).Error; err != nil {
		tx.Rollback()
		return fmt.Errorf("update MilerProfile availability: %w", err)
	}

	tx.Commit()

	utils.Info("CRMAssignment: assigned",
		"booking_id", booking.Bookingid,
		"miler_id", milerUserID,
		"distance_km", candidate.distanceKm,
		"active_bookings", candidate.activeBookings,
		"agent_decision_id", agentDecisionID,
	)

	publishAssignment(booking, milerUserID)
	notifyMilerNewAssignment(candidate.profile, booking.Bookingid)
	notifyCustomerMilerAssigned(booking, candidate.profile.Displayname)

	return nil
}

// publishAssignment sends the booking.assigned event to NATS JetStream.
// Non-fatal: logs a warning and returns if NATS is unavailable or publish fails.
func publishAssignment(booking *models.PickupBooking, milerUserID int) {
	if db.Js == nil {
		return
	}

	payload := map[string]interface{}{
		"booking_id":       booking.Bookingid,
		"booking_no":       booking.Bookingno,
		"miler_id":         milerUserID,
		"provider_company": booking.Providercompany,
		"provider_hub":     booking.Providerlocation,
		"assigned_at":      time.Now().UnixMilli(),
	}

	data, err := json.Marshal(payload)
	if err != nil {
		utils.Warn("CRMAssignment: failed to marshal NATS payload", "booking_id", booking.Bookingid, "error", err)
		return
	}

	if _, err := db.Js.Publish("booking.assigned", data); err != nil {
		utils.Warn("CRMAssignment: NATS publish failed", "booking_id", booking.Bookingid, "error", err)
	}
}
