package controllers

import (
	"fmt"
	"math"
	"sort"
	"strconv"
	"strings"
	"time"

	"doormile/config"
	"doormile/constants"
	"doormile/db"
	"doormile/dto"
	"doormile/internal/assignment"
	"doormile/models"
	"doormile/utils"

	"github.com/gofiber/fiber/v2"
)

// hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for
// TryAssignOnce (GEOSEARCH + AI decision + commit) before returning 202 and
// letting the assignment finish in the background.
const hubAutoAssignTimeout = 8 * time.Second

// assignableBookingStatuses gates both HubAssignMiler and HubAutoAssign.
// Pending_Assignment / Assignment_Failed aren't statuses this codebase
// currently sets anywhere (only Pending_Pickup is real today) but are kept
// here so the gate doesn't need touching if those states get introduced later.
var assignableBookingStatuses = map[string]bool{
	constants.BookingPendingPickup: true,
	"Pending_Assignment":           true,
	"Assignment_Failed":            true,
}

// defaultJourneyMinutes is the assumed total truck transit time until real
// GPS telemetry (EMQX) is wired up; used to interpolate in-transit position.
const defaultJourneyMinutes = 480 // 8 hours

// zoneNames maps a handful of known pincodes in our 4 operating cities to
// human-readable locality names. Unmapped pincodes fall back to "Zone <code>".
var zoneNames = map[string]string{
	"641001": "Gandhipuram",
	"641002": "RS Puram",
	"641004": "Peelamedu",
	"641012": "Jupiter Nagar",
	"641035": "Saravanampatti",
	"500003": "Secunderabad",
	"500032": "Financial District",
	"500034": "Banjara Hills",
	"500081": "Gachibowli",
	"560034": "Koramangala",
	"560038": "Indiranagar",
	"560066": "Whitefield",
	"560100": "Electronic City",
	"600017": "T Nagar",
	"600032": "Guindy",
	"600040": "Anna Nagar",
	"600042": "Velachery",
}

func zoneName(pincode string) string {
	if name, ok := zoneNames[pincode]; ok {
		return name
	}
	return "Zone " + pincode
}

// haversineKM returns the great-circle distance between two lat/lon points in km.
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
	const earthRadiusKM = 6371.0
	toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
	dLat := toRad(lat2 - lat1)
	dLon := toRad(lon2 - lon1)
	a := math.Sin(dLat/2)*math.Sin(dLat/2) +
		math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
	return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}

// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago".
func humanizeRelativeTime(t time.Time) string {
	d := time.Since(t)
	switch {
	case d < time.Minute:
		return "just now"
	case d < time.Hour:
		return fmt.Sprintf("%d min ago", int(d.Minutes()))
	case d < 24*time.Hour:
		return fmt.Sprintf("%d hrs ago", int(d.Hours()))
	default:
		return fmt.Sprintf("%d days ago", int(d.Hours()/24))
	}
}

// todayMidnight returns the start of the current day in server local time,
// used to scope "today" counters on the hub dashboard.
func todayMidnight() time.Time {
	now := time.Now()
	return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}

// parseHubDateRange parses optional from/to (YYYY-MM-DD) query params shared
// by the hub console's date-range-scoped endpoints (dashboard, inbound,
// bookings), defaulting to "today so far" when both are omitted. Mirrors
// GetHubReport's range semantics: to is inclusive of the whole day.
func parseHubDateRange(c *fiber.Ctx) (time.Time, time.Time, error) {
	fromStr := c.Query("from")
	toStr := c.Query("to")

	if fromStr == "" && toStr == "" {
		return todayMidnight(), time.Now(), nil
	}
	if fromStr == "" || toStr == "" {
		return time.Time{}, time.Time{}, fmt.Errorf("both from and to query params are required (YYYY-MM-DD)")
	}

	from, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
	if err != nil {
		return time.Time{}, time.Time{}, fmt.Errorf("invalid from date, expected YYYY-MM-DD")
	}
	toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
	if err != nil {
		return time.Time{}, time.Time{}, fmt.Errorf("invalid to date, expected YYYY-MM-DD")
	}
	to := toDate.Add(24*time.Hour - time.Nanosecond)
	if to.Before(from) {
		return time.Time{}, time.Time{}, fmt.Errorf("to date must not be before from date")
	}
	return from, to, nil
}

// hubPincodePrefix returns the first 3 digits of a hub's pincode, following
// the same zone-prefix convention used in pricing_helpers.go and city_gate.go.
func hubPincodePrefix(hubID int) string {
	var hub models.Hub
	if err := db.DB.Where("hubid = ?", hubID).First(&hub).Error; err != nil || len(hub.Pincode) < 3 {
		return ""
	}
	return hub.Pincode[:3]
}

func HubStaffLogin(cfg *config.Config) fiber.Handler {
	return func(c *fiber.Ctx) error {
		type LoginRequest struct {
			Email    string `json:"email"`
			Password string `json:"password"`
		}
		req := new(LoginRequest)
		if err := c.BodyParser(req); err != nil {
			return utils.BadRequest(c, "invalid request body")
		}

		if req.Email == "" || req.Password == "" {
			return utils.BadRequest(c, "email and password are required")
		}

		var staff models.HubStaffAccount
		if err := db.DB.Where("email = ? AND isactive = ?", req.Email, true).First(&staff).Error; err != nil {
			return utils.Unauthorized(c, "incorrect email or password")
		}

		if !utils.CheckPasswordHash(req.Password, staff.Passwordhash) {
			return utils.Unauthorized(c, "incorrect email or password")
		}

		token, err := utils.GenerateHubStaffToken(staff.Hubstaffaccountid, staff.Email, staff.Hubid, cfg.JWTSecret)
		if err != nil {
			return utils.Internal(c, "failed to generate token")
		}

		var hub models.Hub
		db.DB.Where("hubid = ?", staff.Hubid).First(&hub)

		var loc models.AppLocation
		db.DB.Where("applocationid = ?", hub.Applocationid).First(&loc)

		return c.JSON(fiber.Map{
			"success": true,
			"token":   token,
			"hub": fiber.Map{
				"hubid":    hub.Hubid,
				"hubname":  hub.Hubname,
				"hubtype":  hub.Hubtype,
				"city":     loc.Applocationname,
				"capacity": hub.Capacity,
			},
			"staff": fiber.Map{
				"displayname": staff.Displayname,
				"email":       staff.Email,
				"role":        staff.Roleid,
			},
			"is_doormile_staff": staff.Tenantid == nil,
		})
	}
}

// getCurrentHubStaff reloads the requesting hub staff account fresh from the
// DB (rather than trusting JWT claims) so tenantid changes take effect
// immediately without requiring the staff member to re-login.
func getCurrentHubStaff(c *fiber.Ctx) (*models.HubStaffAccount, error) {
	staffID := c.Locals("userid").(int)
	var staff models.HubStaffAccount
	if err := db.DB.Where("hubstaffaccountid = ?", staffID).First(&staff).Error; err != nil {
		return nil, err
	}
	return &staff, nil
}

func isDoormileStaff(staff *models.HubStaffAccount) bool {
	return staff.Tenantid == nil
}

func GetHubDashboardStats(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	prefix := hubPincodePrefix(hubID)

	from, to, err := parseHubDateRange(c)
	if err != nil {
		return utils.BadRequest(c, err.Error())
	}

	var parcelsReceivedToday int64
	db.DB.Model(&models.Consignment{}).
		Where("currenthubid = ? AND status = ? AND updatedat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to).
		Count(&parcelsReceivedToday)

	var milersAvailable int64
	db.DB.Model(&models.MilerProfile{}).
		Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable).
		Count(&milersAvailable)

	var milersOnDuty int64
	db.DB.Model(&models.MilerProfile{}).
		Where("hubid = ? AND availabilitystatus != ?", hubID, constants.MilerOffline).
		Count(&milersOnDuty)

	pendingQuery := db.DB.Model(&models.PickupBooking{}).Where("status = ?", constants.BookingPendingPickup)
	if prefix != "" {
		pendingQuery = pendingQuery.Where("pickuppincode LIKE ?", prefix+"%")
	}
	var pendingPickups int64
	pendingQuery.Count(&pendingPickups)

	var batchesSentToday int64
	db.DB.Model(&models.Tripsheet{}).
		Where("sourcehubid = ? AND dispatchtime BETWEEN ? AND ?", hubID, from, to).
		Count(&batchesSentToday)

	var exceptions int64
	db.DB.Model(&models.ConsignmentException{}).
		Where("hubid = ? AND createdat BETWEEN ? AND ?", hubID, from, to).
		Count(&exceptions)

	// Sorting happens at inbound scan (CreateInboundScan assigns a shelf), so
	// today's inwarded count doubles as "parcels sorted today"; the target is
	// the hub's own declared daily capacity rather than a made-up constant.
	var hub models.Hub
	db.DB.Where("hubid = ?", hubID).First(&hub)

	return utils.OK(c, fiber.Map{
		"parcels_received_today": parcelsReceivedToday,
		"milers_available":       milersAvailable,
		"milers_on_duty":         milersOnDuty,
		"pending_pickups":        pendingPickups,
		"batches_sent_today":     batchesSentToday,
		"exceptions":             exceptions,
		"parcels_sorted":         parcelsReceivedToday,
		"sorting_target":         hub.Capacity,
	})
}

func GetHubUnassignedBookings(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	prefix := hubPincodePrefix(hubID)

	query := db.DB.Preload("Parcels").
		Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup)
	if prefix != "" {
		query = query.Where("pickuppincode LIKE ?", prefix+"%")
	}

	var bookings []models.PickupBooking
	if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
		return utils.Internal(c, "failed to fetch unassigned bookings")
	}

	response := make([]fiber.Map, 0, len(bookings))
	for _, b := range bookings {
		var customer models.AppCustomer
		db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)

		response = append(response, fiber.Map{
			"bookingid":        b.Bookingid,
			"bookingno":        b.Bookingno,
			"customer_name":    strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
			"pickup_address":   b.Pickupaddress,
			"pickup_pincode":   b.Pickuppincode,
			"delivery_address": b.Deliveryaddress,
			"parcels":          b.Parcels,
			"created_at":       b.Createdat,
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// hubBookingDisplayStatus normalizes a booking's raw status into the small
// vocabulary the hub console's history views key off: "pending" (needs a
// miler), "cancelled", or a handled state ("assigned", "picked_up",
// "delivered").
func hubBookingDisplayStatus(status string, hasMiler bool) string {
	if status == constants.BookingCancelled {
		return "cancelled"
	}
	if !hasMiler {
		return "pending"
	}
	switch status {
	case constants.BookingPickedUp:
		return "picked_up"
	case constants.BookingConvertedConsignment:
		return "delivered"
	default:
		return "assigned"
	}
}

// GetHubBookingsRange returns all pickup requests created at this hub in
// [from, to] (both YYYY-MM-DD, inclusive), each with its current assignment
// status and miler. Historical superset of GetHubUnassignedBookings, which
// stays as the live "needs a miler now" view.
func GetHubBookingsRange(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	prefix := hubPincodePrefix(hubID)

	from, to, err := parseHubDateRange(c)
	if err != nil {
		return utils.BadRequest(c, err.Error())
	}

	query := db.DB.Preload("Parcels").Where("createdat BETWEEN ? AND ?", from, to)
	if prefix != "" {
		query = query.Where("pickuppincode LIKE ?", prefix+"%")
	}

	var bookings []models.PickupBooking
	if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
		return utils.Internal(c, "failed to fetch bookings")
	}

	response := make([]fiber.Map, 0, len(bookings))
	for _, b := range bookings {
		var customer models.AppCustomer
		db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)

		hasMiler := b.Assignedmileruserid != nil
		var milerName interface{}
		if hasMiler {
			var mp models.MilerProfile
			if db.DB.Where("userid = ?", *b.Assignedmileruserid).First(&mp).Error == nil {
				milerName = mp.Displayname
			}
		}

		response = append(response, fiber.Map{
			"bookingid":        b.Bookingid,
			"bookingno":        b.Bookingno,
			"customer_name":    strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
			"pickup_address":   b.Pickupaddress,
			"pickup_pincode":   b.Pickuppincode,
			"delivery_address": b.Deliveryaddress,
			"parcels":          b.Parcels,
			"status":           hubBookingDisplayStatus(b.Status, hasMiler),
			"milername":        milerName,
			"created_at":       b.Createdat,
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// renderInboundConsignment builds the hub console's inbound-parcel row,
// shared by GetHubInboundToday and GetHubInboundRange.
func renderInboundConsignment(cs models.Consignment) fiber.Map {
	originName := fmt.Sprintf("Direct pickup (%s)", cs.Pickuppincode)
	if cs.Originhubid != nil {
		var oh models.Hub
		if db.DB.Where("hubid = ?", *cs.Originhubid).First(&oh).Error == nil {
			originName = oh.Hubname
		}
	}

	return fiber.Map{
		"consignmentid": cs.Consignmentid,
		"trackingno":    cs.Trackingno,
		// sendername is empty: consignments carry no FK back to any
		// customer/booking record (Senderid/Receiverid are never
		// populated anywhere in this codebase).
		"sendername": "",
		"originname": originName,
		// destinationname: consignments store no free-text delivery
		// address, only deliverypincode — used as the best available label.
		"destinationname":  cs.Deliverypincode,
		"deliverypincode":  cs.Deliverypincode,
		"weight":           fmt.Sprintf("%.1f kg", cs.Chargeableweight),
		"chargeableweight": cs.Chargeableweight,
		"condition":        cs.Condition,
		"shelf":            cs.Shelf,
		// temperature: no column exists anywhere yet for this.
		"temperature": "N/A",
		"status":      cs.Status,
		"updatedat":   cs.Updatedat,
	}
}

func GetHubInboundToday(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	midnight := todayMidnight()

	var consignments []models.Consignment
	if err := db.DB.Where("currenthubid = ? AND status = ? AND updatedat >= ?", hubID, constants.ConsignmentInwardedAtHub, midnight).
		Order("updatedat DESC").Find(&consignments).Error; err != nil {
		return utils.Internal(c, "failed to fetch inbound consignments")
	}

	response := make([]fiber.Map, 0, len(consignments))
	for _, cs := range consignments {
		response = append(response, renderInboundConsignment(cs))
	}

	return utils.List(c, response, int64(len(response)))
}

// GetHubInboundRange returns parcels received/inbounded at this hub whose
// inbound timestamp falls in [from, to] (both YYYY-MM-DD, inclusive),
// defaulting to today when omitted. Historical counterpart to
// GetHubInboundToday, which stays as the live "since midnight" view.
func GetHubInboundRange(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)

	from, to, err := parseHubDateRange(c)
	if err != nil {
		return utils.BadRequest(c, err.Error())
	}

	var consignments []models.Consignment
	if err := db.DB.Where("currenthubid = ? AND status = ? AND updatedat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to).
		Order("updatedat DESC").Find(&consignments).Error; err != nil {
		return utils.Internal(c, "failed to fetch inbound consignments")
	}

	response := make([]fiber.Map, 0, len(consignments))
	for _, cs := range consignments {
		response = append(response, renderInboundConsignment(cs))
	}

	return utils.List(c, response, int64(len(response)))
}

func CreateInboundScan(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	idParam := c.Params("id")

	type InboundRequest struct {
		TrackingID  string `json:"tracking_id"`
		Condition   string `json:"condition"`
		Temperature string `json:"temperature"`
		Shelf       string `json:"shelf"`
		Weight      string `json:"weight"`
	}
	req := new(InboundRequest)
	if err := c.BodyParser(req); err != nil {
		return utils.BadRequest(c, "invalid request body")
	}

	var consignment models.Consignment
	found := false
	if id, err := strconv.Atoi(idParam); err == nil {
		if db.DB.Where("consignmentid = ?", id).First(&consignment).Error == nil {
			found = true
		}
	}
	if !found && idParam != "" {
		if db.DB.Where("trackingno = ?", idParam).First(&consignment).Error == nil {
			found = true
		}
	}
	if !found && req.TrackingID != "" {
		if db.DB.Where("trackingno = ?", req.TrackingID).First(&consignment).Error == nil {
			found = true
		}
	}
	if !found {
		return utils.NotFound(c, "consignment not found")
	}

	recommendedShelf := req.Shelf
	if recommendedShelf == "" {
		switch {
		case strings.Contains(req.Condition, "Damaged"):
			recommendedShelf = "Exception Area"
		case req.Temperature != "" && req.Temperature != "N/A":
			recommendedShelf = "Zone C (Cold Room)"
		default:
			recommendedShelf = "Zone A"
			if n := len(consignment.Deliverypincode); n > 0 {
				lastDigit := consignment.Deliverypincode[n-1]
				if lastDigit >= '0' && lastDigit <= '9' && (lastDigit-'0')%2 == 0 {
					recommendedShelf = "Zone B"
				}
			}
		}
	}

	consignment.Status = constants.ConsignmentInwardedAtHub
	consignment.Currenthubid = &hubID
	consignment.Condition = req.Condition
	consignment.Shelf = recommendedShelf
	consignment.Updatedat = time.Now()

	if err := db.DB.Save(&consignment).Error; err != nil {
		return utils.Internal(c, "failed to update consignment")
	}

	history := models.ConsignmentHistory{
		Consignmentid: consignment.Consignmentid,
		Hubid:         &hubID,
		Eventstatus:   constants.ConsignmentInwardedAtHub,
		Remarks:       fmt.Sprintf("Inbound scan at hub: condition=%s, shelf=%s", req.Condition, recommendedShelf),
	}
	db.DB.Create(&history)

	if strings.Contains(req.Condition, "Damaged") {
		exception := models.ConsignmentException{
			Consignmentid: consignment.Consignmentid,
			Hubid:         &hubID,
			Exceptiontype: constants.ExceptionDamaged,
			Description:   fmt.Sprintf("Flagged during hub inbound scan: %s", req.Condition),
		}
		db.DB.Create(&exception)
	}

	return utils.OK(c, fiber.Map{
		"consignment":       consignment,
		"recommended_shelf": recommendedShelf,
	})
}

// --------------------
// HUB BATCHES (DISPATCH) — backed by the existing Tripsheet model
// --------------------

// GetHubBatches lists this hub's outgoing batches. With no from/to it
// returns every batch (unchanged default); passing from/to (YYYY-MM-DD,
// inclusive) scopes it to batches created in that range.
func GetHubBatches(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)

	query := db.DB.Where("sourcehubid = ? AND deletedat IS NULL", hubID)
	if c.Query("from") != "" || c.Query("to") != "" {
		from, to, err := parseHubDateRange(c)
		if err != nil {
			return utils.BadRequest(c, err.Error())
		}
		query = query.Where("createdat BETWEEN ? AND ?", from, to)
	}

	var tripsheets []models.Tripsheet
	if err := query.Order("createdat DESC").Find(&tripsheets).Error; err != nil {
		return utils.Internal(c, "failed to fetch batches")
	}

	response := make([]fiber.Map, 0, len(tripsheets))
	for _, ts := range tripsheets {
		var itemCount int64
		db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount)

		response = append(response, fiber.Map{
			"tripsheetid":  ts.Tripsheetid,
			"tripsheetno":  ts.Tripsheetno,
			"route":        ts.Batchlabel,
			"destination":  ts.Destinationlabel,
			"kind":         ts.Batchkind,
			"status":       ts.Status,
			"item_count":   itemCount,
			"dispatchtime": ts.Dispatchtime,
			"createdat":    ts.Createdat,
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// resolveDestinationHubID best-effort matches a free-text destination (e.g.
// "Mumbai Hub (BOM-02)") against a real Hub row by name, since
// tripsheets.destinationhubid carries a NOT NULL foreign key to hubs. When no
// internal hub matches (interstate transfer to a city we don't operate a hub
// in yet), it falls back to the batch's own source hub, which is always a
// valid id; the human-readable destination is preserved in Destinationlabel.
func resolveDestinationHubID(destination string, fallback int) int {
	if destination == "" {
		return fallback
	}
	var hub models.Hub
	if err := db.DB.Where("hubname ILIKE ?", "%"+destination+"%").First(&hub).Error; err == nil {
		return hub.Hubid
	}
	return fallback
}

func CreateHubBatch(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	staffID := c.Locals("userid").(int)

	type BatchRequest struct {
		Route        string `json:"route"`
		Destination  string `json:"destination"`
		Vehicle      string `json:"vehicle"`
		ParcelsCount int    `json:"parcels_count"`
		Kind         string `json:"kind"`
	}
	req := new(BatchRequest)
	if err := c.BodyParser(req); err != nil {
		return utils.BadRequest(c, "invalid request body")
	}

	if req.Route == "" {
		return utils.BadRequest(c, "route is required")
	}

	kind := req.Kind
	if kind == "" {
		kind = "local"
	}

	tripsheet := models.Tripsheet{
		Tripsheetno:      generateTripsheetNo(),
		Sourcehubid:      hubID,
		Destinationhubid: resolveDestinationHubID(req.Destination, hubID),
		Batchlabel:       req.Route,
		Batchkind:        kind,
		Destinationlabel: req.Destination,
		Status:           constants.TripsheetDraft,
		Createdby:        staffID,
		Updatedby:        staffID,
	}

	if err := db.DB.Create(&tripsheet).Error; err != nil {
		return utils.Internal(c, "failed to create batch")
	}

	return utils.Created(c, fiber.Map{
		"tripsheetid":   tripsheet.Tripsheetid,
		"tripsheetno":   tripsheet.Tripsheetno,
		"route":         tripsheet.Batchlabel,
		"destination":   tripsheet.Destinationlabel,
		"kind":          tripsheet.Batchkind,
		"status":        tripsheet.Status,
		"vehicle":       req.Vehicle,
		"parcels_count": req.ParcelsCount,
	})
}

func UpdateBatchStatus(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	id, _ := strconv.Atoi(c.Params("id"))

	type StatusUpdate struct {
		Status string `json:"status"`
	}
	req := new(StatusUpdate)
	if err := c.BodyParser(req); err != nil {
		return utils.BadRequest(c, "invalid request body")
	}

	validStatuses := map[string]bool{
		constants.TripsheetDraft:      true,
		constants.TripsheetReady:      true,
		constants.TripsheetDispatched: true,
	}
	if !validStatuses[req.Status] {
		return utils.BadRequest(c, "status must be one of Draft, Ready, Dispatched")
	}

	var tripsheet models.Tripsheet
	if err := db.DB.Where("tripsheetid = ? AND sourcehubid = ? AND deletedat IS NULL", id, hubID).First(&tripsheet).Error; err != nil {
		return utils.NotFound(c, "batch not found")
	}

	tripsheet.Status = req.Status
	tripsheet.Updatedat = time.Now()
	if req.Status == constants.TripsheetDispatched {
		now := time.Now()
		tripsheet.Dispatchtime = &now
	}

	if err := db.DB.Save(&tripsheet).Error; err != nil {
		return utils.Internal(c, "failed to update batch status")
	}

	return utils.OK(c, tripsheet)
}

// GetHubMilers is the hub-scoped equivalent of admin's GetMilers, filtered to
// only the milers assigned to the requesting hub staff's own hub, enriched
// with computed operational stats per miler.
func GetHubMilers(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	midnight := todayMidnight()
	weekAgo := time.Now().AddDate(0, 0, -7)

	var profiles []models.MilerProfile
	if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil {
		return utils.Internal(c, "failed to fetch milers")
	}

	response := make([]fiber.Map, 0, len(profiles))
	for _, mp := range profiles {
		var zones []string
		db.DB.Model(&models.BookingAssignment{}).
			Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid").
			Where("bookingassignments.mileruserid = ? AND bookingassignments.assignedat >= ?", mp.Userid, weekAgo).
			Distinct("pb.pickuppincode").
			Pluck("pb.pickuppincode", &zones)
		if zones == nil {
			zones = []string{}
		}

		// assignmentstatus (BookingAssignment's real status column) only has
		// Assigned/Accepted/Rejected/Reassigned/Completed/Cancelled — treated
		// Assigned+Accepted as "still active load".
		var assignedLoad int64
		db.DB.Model(&models.BookingAssignment{}).
			Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid,
				[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
			Count(&assignedLoad)

		// Pending_Pickup lives on pickupbookings.status, not on the
		// assignment itself, so this counts via a join.
		var pickupsPending int64
		db.DB.Model(&models.BookingAssignment{}).
			Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid").
			Where("bookingassignments.mileruserid = ? AND pb.status = ?", mp.Userid, constants.BookingPendingPickup).
			Count(&pickupsPending)

		// bookingpayments has no mileruserid column; collectedbyuserid is the
		// app user id of whoever collected it, which for miler collections is
		// the miler's own userid.
		var codCollected float64
		db.DB.Model(&models.BookingPayment{}).
			Where("collectedbyuserid = ? AND paymentstatus = ? AND createdat >= ?", mp.Userid, constants.PaymentStatusPaid, midnight).
			Select("COALESCE(SUM(amount), 0)").Scan(&codCollected)

		var codPending float64
		db.DB.Model(&models.BookingPayment{}).
			Where("collectedbyuserid = ? AND paymentstatus = ?", mp.Userid, constants.PaymentStatusPending).
			Select("COALESCE(SUM(amount), 0)").Scan(&codPending)

		var firstAssignment models.BookingAssignment
		var checkinAt *time.Time
		if db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight).
			Order("assignedat ASC").First(&firstAssignment).Error == nil {
			checkinAt = &firstAssignment.Assignedat
		}

		hoursActive := 0.0
		if checkinAt != nil {
			hoursActive = time.Since(*checkinAt).Hours()
		}

		var vehicleNo string
		if mp.Vehicleid != nil {
			var v models.Vehicle
			if db.DB.Where("vehicleid = ?", *mp.Vehicleid).First(&v).Error == nil {
				vehicleNo = v.Vehicleno
			}
		}

		response = append(response, fiber.Map{
			"milerprofileid":        mp.Milerprofileid,
			"userid":                mp.Userid,
			"displayname":           mp.Displayname,
			"phone":                 mp.Phone,
			"vehicleid":             mp.Vehicleid,
			"hubid":                 mp.Hubid,
			"defaultvehicletype":    mp.Defaultvehicletype,
			"vehicleno":             vehicleNo,
			"currentlatitude":       mp.Currentlatitude,
			"currentlongitude":      mp.Currentlongitude,
			"currentpincode":        mp.Currentpincode,
			"availabilitystatus":    mp.Availabilitystatus,
			"rating":                mp.Rating,
			"totalcompletedpickups": mp.Totalcompletedpickups,
			"totalcancelledpickups": mp.Totalcancelledpickups,
			"zones":                 zones,
			"assignedload":          assignedLoad,
			"capacity":              30, // hardcoded until vehicle capacity is modelled
			"pickupspending":        pickupsPending,
			"codcollected":          codCollected,
			"codpending":            codPending,
			"checkinat":             checkinAt,
			"hoursactive":           hoursActive,
			"isverified":            mp.Devicetoken != "",
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// --------------------
// HUB MESSAGES / CHAT
// --------------------

// ensureHubConversations lazily creates a HubConversation for every miler
// currently assigned to this hub that doesn't already have one, so the
// message list always reflects the hub's real roster without needing a
// separate provisioning endpoint.
func ensureHubConversations(hubID int) {
	var profiles []models.MilerProfile
	if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil {
		return
	}

	for _, mp := range profiles {
		var existing models.HubConversation
		if db.DB.Where("hubid = ? AND mileruserid = ?", hubID, mp.Userid).First(&existing).Error == nil {
			continue
		}
		userid := mp.Userid
		conv := models.HubConversation{
			Hubid:           hubID,
			Mileruserid:     &userid,
			Participantname: mp.Displayname,
			Participantrole: "Miler",
		}
		db.DB.Create(&conv)
	}
}

// GetHubMessages returns the hub's conversation list, ordered by most recent
// activity, with each conversation's last message and unread count.
func GetHubMessages(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	ensureHubConversations(hubID)

	var conversations []models.HubConversation
	if err := db.DB.Where("hubid = ?", hubID).Order("updatedat DESC").Find(&conversations).Error; err != nil {
		return utils.Internal(c, "failed to fetch conversations")
	}

	response := make([]fiber.Map, 0, len(conversations))
	for _, conv := range conversations {
		var lastMsg models.HubMessage
		lastMessageText := ""
		lastTime := conv.Createdat
		if db.DB.Where("hubconversationid = ?", conv.Hubconversationid).
			Order("createdat DESC").First(&lastMsg).Error == nil {
			lastMessageText = lastMsg.Messagetext
			lastTime = lastMsg.Createdat
		}

		var unread int64
		db.DB.Model(&models.HubMessage{}).
			Where("hubconversationid = ? AND sender = 'them' AND isread = false", conv.Hubconversationid).
			Count(&unread)

		response = append(response, fiber.Map{
			"id":          conv.Hubconversationid,
			"name":        conv.Participantname,
			"lastmessage": lastMessageText,
			"time":        humanizeRelativeTime(lastTime),
			"unread":      unread,
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// GetHubMessageThread returns one conversation's full message history.
func GetHubMessageThread(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	id, err := strconv.Atoi(c.Params("id"))
	if err != nil {
		return utils.BadRequest(c, "invalid conversation id")
	}

	var conv models.HubConversation
	if err := db.DB.Where("hubconversationid = ? AND hubid = ?", id, hubID).First(&conv).Error; err != nil {
		return utils.NotFound(c, "conversation not found")
	}

	var messages []models.HubMessage
	db.DB.Where("hubconversationid = ?", id).Order("createdat ASC").Find(&messages)

	msgResponse := make([]fiber.Map, 0, len(messages))
	for _, m := range messages {
		msgResponse = append(msgResponse, fiber.Map{
			"sender": m.Sender,
			"text":   m.Messagetext,
			"time":   m.Createdat.Local().Format("3:04 PM"),
		})
	}

	return utils.OK(c, fiber.Map{
		"id":       conv.Hubconversationid,
		"name":     conv.Participantname,
		"messages": msgResponse,
	})
}

// SendHubMessage posts a reply from the logged-in hub staff into a conversation.
func SendHubMessage(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	staffID := c.Locals("userid").(int)
	id, err := strconv.Atoi(c.Params("id"))
	if err != nil {
		return utils.BadRequest(c, "invalid conversation id")
	}

	type SendMessageRequest struct {
		Text string `json:"text"`
	}
	req := new(SendMessageRequest)
	if err := c.BodyParser(req); err != nil {
		return utils.BadRequest(c, "invalid request body")
	}
	if strings.TrimSpace(req.Text) == "" {
		return utils.BadRequest(c, "text is required")
	}

	var conv models.HubConversation
	if err := db.DB.Where("hubconversationid = ? AND hubid = ?", id, hubID).First(&conv).Error; err != nil {
		return utils.NotFound(c, "conversation not found")
	}

	message := models.HubMessage{
		Hubconversationid: id,
		Sender:            "me",
		Senderstaffid:     &staffID,
		Messagetext:       req.Text,
		Isread:            true,
	}
	if err := db.DB.Create(&message).Error; err != nil {
		return utils.Internal(c, "failed to send message")
	}

	conv.Updatedat = time.Now()
	db.DB.Save(&conv)

	return utils.OK(c, fiber.Map{
		"sender": "me",
		"text":   message.Messagetext,
		"time":   message.Createdat.Local().Format("3:04 PM"),
	})
}

// MarkHubMessagesRead clears the unread flag on every inbound ("them")
// message in a conversation.
func MarkHubMessagesRead(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	id, err := strconv.Atoi(c.Params("id"))
	if err != nil {
		return utils.BadRequest(c, "invalid conversation id")
	}

	var conv models.HubConversation
	if err := db.DB.Where("hubconversationid = ? AND hubid = ?", id, hubID).First(&conv).Error; err != nil {
		return utils.NotFound(c, "conversation not found")
	}

	db.DB.Model(&models.HubMessage{}).
		Where("hubconversationid = ? AND sender = 'them' AND isread = false", id).
		Update("isread", true)

	return utils.Message(c, "messages marked as read")
}

// --------------------
// HUB MANAGEMENT (Doormile staff only)
// --------------------

// CreateHubStaffAccount lets Doormile staff (tenantid == nil) provision new
// hub staff accounts, including partner-tenant accounts scoped to one hub.
func CreateHubStaffAccount(c *fiber.Ctx) error {
	current, err := getCurrentHubStaff(c)
	if err != nil {
		return utils.Internal(c, "failed to verify requesting staff account")
	}
	if !isDoormileStaff(current) {
		return utils.Forbidden(c, "only Doormile staff can create hub staff accounts")
	}

	type CreateStaffRequest struct {
		Hubid       int    `json:"hubid"`
		Email       string `json:"email"`
		Password    string `json:"password"`
		Displayname string `json:"displayname"`
		Tenantid    *int   `json:"tenantid"`
	}
	req := new(CreateStaffRequest)
	if err := c.BodyParser(req); err != nil {
		return utils.BadRequest(c, "invalid request body")
	}

	if req.Hubid == 0 || req.Email == "" || req.Password == "" {
		return utils.BadRequest(c, "hubid, email and password are required")
	}

	var hub models.Hub
	if err := db.DB.Where("hubid = ? AND deletedat IS NULL", req.Hubid).First(&hub).Error; err != nil {
		return utils.NotFound(c, "hub not found")
	}

	passHash, err := utils.HashPassword(req.Password)
	if err != nil {
		return utils.Internal(c, "failed to hash password")
	}

	staff := models.HubStaffAccount{
		Hubid:        req.Hubid,
		Email:        req.Email,
		Passwordhash: passHash,
		Displayname:  req.Displayname,
		Roleid:       6,
		Isactive:     true,
		Tenantid:     req.Tenantid,
	}

	if err := db.DB.Create(&staff).Error; err != nil {
		return utils.Internal(c, "failed to create hub staff account")
	}

	return utils.Created(c, fiber.Map{
		"hubstaffaccountid": staff.Hubstaffaccountid,
		"hubid":             staff.Hubid,
		"email":             staff.Email,
		"displayname":       staff.Displayname,
		"tenantid":          staff.Tenantid,
	})
}

// GetHubsInCity lists every hub in the requesting staff's city (same
// applocationid as their own hub), noting whether each already has a staff
// account so the console can flag hubs still needing one.
func GetHubsInCity(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)

	var myHub models.Hub
	if err := db.DB.Where("hubid = ?", hubID).First(&myHub).Error; err != nil {
		return utils.Internal(c, "failed to resolve current hub")
	}

	var hubs []models.Hub
	if err := db.DB.Where("applocationid = ? AND deletedat IS NULL", myHub.Applocationid).
		Order("hubid").Find(&hubs).Error; err != nil {
		return utils.Internal(c, "failed to fetch hubs")
	}

	response := make([]fiber.Map, 0, len(hubs))
	for _, h := range hubs {
		var staffCount int64
		db.DB.Model(&models.HubStaffAccount{}).Where("hubid = ?", h.Hubid).Count(&staffCount)

		response = append(response, fiber.Map{
			"hubid":     h.Hubid,
			"hubname":   h.Hubname,
			"hubtype":   h.Hubtype,
			"pincode":   h.Pincode,
			"status":    h.Status,
			"capacity":  h.Capacity,
			"lat":       h.Latitude,
			"lon":       h.Longitude,
			"has_staff": staffCount > 0,
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// CreateCityHub lets Doormile staff add a new hub, forced into their own
// city (applocationid) regardless of what the request body sends.
func CreateCityHub(c *fiber.Ctx) error {
	current, err := getCurrentHubStaff(c)
	if err != nil {
		return utils.Internal(c, "failed to verify requesting staff account")
	}
	if !isDoormileStaff(current) {
		return utils.Forbidden(c, "only Doormile staff can create new hubs")
	}

	hubID := c.Locals("hubid").(int)
	var myHub models.Hub
	if err := db.DB.Where("hubid = ?", hubID).First(&myHub).Error; err != nil {
		return utils.Internal(c, "failed to resolve current hub")
	}

	req := new(dto.HubCreateRequest)
	if err := c.BodyParser(req); err != nil {
		return utils.BadRequest(c, "invalid request body")
	}

	if req.Hubname == "" || req.Hubtype == "" {
		return utils.BadRequest(c, "hubname and hubtype are required")
	}

	hub := models.Hub{
		Hubname:       req.Hubname,
		Hubtype:       req.Hubtype,
		Applocationid: myHub.Applocationid,
		Contactno:     req.Contactno,
		Address:       req.Address,
		Latitude:      req.Latitude,
		Longitude:     req.Longitude,
		Pincode:       req.Pincode,
		Status:        req.Status,
	}
	if hub.Status == "" {
		hub.Status = "Active"
	}

	if err := db.DB.Create(&hub).Error; err != nil {
		return utils.Internal(c, "failed to create hub")
	}

	return utils.Created(c, hub)
}

// --------------------
// IN-TRANSIT TRACKING & INBOUND VEHICLES
// --------------------

// GetInTransitTripsheets returns tripsheets currently moving to/from this hub,
// with a linearly-interpolated position along the source→dest line since we
// don't have real truck GPS yet. Swap in real coordinates later — shape stays.
func GetInTransitTripsheets(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)

	var tripsheets []models.Tripsheet
	if err := db.DB.Where("(sourcehubid = ? OR destinationhubid = ?) AND status IN ? AND deletedat IS NULL",
		hubID, hubID, []string{constants.TripsheetDispatched, "In_Transit"}).
		Order("dispatchtime DESC").Find(&tripsheets).Error; err != nil {
		return utils.Internal(c, "failed to fetch in-transit tripsheets")
	}

	response := make([]fiber.Map, 0, len(tripsheets))
	for _, ts := range tripsheets {
		var source, dest models.Hub
		db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source)
		db.DB.Where("hubid = ?", ts.Destinationhubid).First(&dest)

		var vehicleNo string
		if ts.Vehicleid != nil {
			var v models.Vehicle
			if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil {
				vehicleNo = v.Vehicleno
			}
		}

		var itemCount int64
		db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount)

		elapsedMinutes := 0.0
		if ts.Dispatchtime != nil {
			elapsedMinutes = time.Since(*ts.Dispatchtime).Minutes()
		}
		progressPct := int(math.Min(95, (elapsedMinutes/defaultJourneyMinutes)*100))
		if progressPct < 0 {
			progressPct = 0
		}
		etaMinutes := int(defaultJourneyMinutes - elapsedMinutes)
		if etaMinutes < 0 {
			etaMinutes = 0
		}

		frac := float64(progressPct) / 100.0
		currentLat := source.Latitude + (dest.Latitude-source.Latitude)*frac
		currentLon := source.Longitude + (dest.Longitude-source.Longitude)*frac

		response = append(response, fiber.Map{
			"tripsheetid":  ts.Tripsheetid,
			"tripsheetno":  ts.Tripsheetno,
			"label":        fmt.Sprintf("%s → %s", source.Hubname, dest.Hubname),
			"originlat":    source.Latitude,
			"originlon":    source.Longitude,
			"destlat":      dest.Latitude,
			"destlon":      dest.Longitude,
			"currentlat":   currentLat,
			"currentlon":   currentLon,
			"status":       ts.Status,
			"progresspct":  progressPct,
			"vehicleno":    vehicleNo,
			"itemcount":    itemCount,
			"dispatchtime": ts.Dispatchtime,
			"eta_minutes":  etaMinutes,
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// GetInboundVehicles returns vehicles arriving at or expected at this hub.
func GetInboundVehicles(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)

	var tripsheets []models.Tripsheet
	if err := db.DB.Where("destinationhubid = ? AND status IN ? AND deletedat IS NULL",
		hubID, []string{constants.TripsheetDispatched, "In_Transit", constants.TripsheetArrived}).
		Order("dispatchtime DESC").Limit(20).Find(&tripsheets).Error; err != nil {
		return utils.Internal(c, "failed to fetch inbound vehicles")
	}

	response := make([]fiber.Map, 0, len(tripsheets))
	for _, ts := range tripsheets {
		var source models.Hub
		db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source)

		var vehicleNo, vehicleType string
		if ts.Vehicleid != nil {
			var v models.Vehicle
			if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil {
				vehicleNo = v.Vehicleno
				vehicleType = v.Vehicletype
			}
		}

		var totalItems, unloadedItems int64
		db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&totalItems)

		status := "On the way"
		unloadedPct := 0
		if ts.Status == constants.TripsheetArrived {
			status = "Unloading"
			db.DB.Model(&models.TripsheetItem{}).
				Where("tripsheetid = ? AND scanstatus = ?", ts.Tripsheetid, constants.ScanUnloaded).
				Count(&unloadedItems)
			if totalItems > 0 {
				unloadedPct = int(float64(unloadedItems) / float64(totalItems) * 100)
			}
		}

		eta := "Arrived"
		if ts.Status != constants.TripsheetArrived && ts.Dispatchtime != nil {
			remaining := defaultJourneyMinutes - int(time.Since(*ts.Dispatchtime).Minutes())
			if remaining < 0 {
				remaining = 0
			}
			eta = fmt.Sprintf("%d hrs %d min", remaining/60, remaining%60)
		}

		response = append(response, fiber.Map{
			"vehicleno":   vehicleNo,
			"vehicletype": vehicleType,
			"origin":      source.Hubname,
			"tripsheetid": ts.Tripsheetid,
			"status":      status,
			"eta":         eta,
			"unloadedpct": unloadedPct,
			"itemcount":   totalItems,
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// --------------------
// HUB ACTIVITY FEED
// --------------------

type activityEntry struct {
	Time time.Time `gorm:"column:time"`
	Type string    `gorm:"column:type"`
	Text string    `gorm:"column:text"`
}

// GetHubActivity aggregates real events from 4 sources (inbound scans,
// dispatches, exceptions, miler assignments) into one timeline.
func GetHubActivity(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	limit, err := strconv.Atoi(c.Query("limit", "10"))
	if err != nil || limit <= 0 {
		limit = 10
	}
	midnight := todayMidnight()

	// Source 4 (miler assignments) is scoped by mp.hubid via a join — the
	// ticket's version had no hub filter at all, which would leak every
	// hub's assignment activity into every other hub's feed.
	query := `
		(SELECT c.updatedat AS time, 'inbound' AS type,
			('Received parcel ' || c.trackingno || ' from ' || COALESCE(oh.hubname, 'Direct Pickup')) AS text
		 FROM consignments c
		 LEFT JOIN hubs oh ON c.originhubid = oh.hubid
		 WHERE c.currenthubid = ? AND c.status = ? AND c.updatedat > ?)
		UNION ALL
		(SELECT t.dispatchtime AS time, 'dispatch' AS type,
			('Dispatched batch ' || t.tripsheetno || ' to ' || COALESCE(t.destinationlabel, '')) AS text
		 FROM tripsheets t
		 WHERE t.sourcehubid = ? AND t.dispatchtime > ?)
		UNION ALL
		(SELECT ce.createdat AS time, 'exception' AS type,
			('Exception raised: ' || ce.exceptiontype || ' on ' || c.trackingno) AS text
		 FROM consignmentexceptions ce
		 JOIN consignments c ON ce.consignmentid = c.consignmentid
		 WHERE ce.hubid = ? AND ce.createdat > ?)
		UNION ALL
		(SELECT ba.assignedat AS time, 'sorting' AS type,
			('Assigned booking #' || ba.bookingid || ' to miler ' || mp.displayname) AS text
		 FROM bookingassignments ba
		 JOIN milerprofiles mp ON ba.mileruserid = mp.userid
		 WHERE mp.hubid = ? AND ba.assignedat > ?)
		ORDER BY time DESC
		LIMIT ?
	`

	var entries []activityEntry
	if err := db.DB.Raw(query,
		hubID, constants.ConsignmentInwardedAtHub, midnight,
		hubID, midnight,
		hubID, midnight,
		hubID, midnight,
		limit,
	).Scan(&entries).Error; err != nil {
		return utils.Internal(c, "failed to fetch hub activity")
	}

	response := make([]fiber.Map, 0, len(entries))
	for _, e := range entries {
		response = append(response, fiber.Map{
			"time": e.Time,
			"type": e.Type,
			"text": e.Text,
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// --------------------
// DELIVERY ZONES
// --------------------

// GetHubZones returns delivery zone breakdown for this hub's milers, falling
// back to static known zones for the hub's city when there's too little
// assignment data yet (new hub, few bookings).
func GetHubZones(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)

	type zoneRow struct {
		Zone    string `gorm:"column:zone"`
		Milers  int64  `gorm:"column:milers"`
		Parcels int64  `gorm:"column:parcels"`
		Status  string `gorm:"column:status"`
	}

	// ba.pickuppincode / ba.status don't exist on bookingassignments —
	// pincode lives on pickupbookings (joined here) and the real status
	// column is assignmentstatus.
	query := `
		SELECT pb.pickuppincode AS zone,
			COUNT(DISTINCT ba.mileruserid) AS milers,
			COUNT(ba.bookingassignmentid) AS parcels,
			CASE WHEN COUNT(DISTINCT ba.mileruserid) = 0 THEN 'Need Milers' ELSE 'Active' END AS status
		FROM bookingassignments ba
		JOIN milerprofiles mp ON ba.mileruserid = mp.userid
		JOIN pickupbookings pb ON pb.bookingid = ba.bookingid
		WHERE mp.hubid = ? AND ba.assignmentstatus IN ?
		GROUP BY pb.pickuppincode
		ORDER BY parcels DESC
		LIMIT 10
	`

	var rows []zoneRow
	db.DB.Raw(query, hubID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).Scan(&rows)

	seen := make(map[string]bool)
	response := make([]fiber.Map, 0, len(rows))
	for _, r := range rows {
		seen[r.Zone] = true
		response = append(response, fiber.Map{
			"zone":     r.Zone,
			"zonename": zoneName(r.Zone),
			"parcels":  r.Parcels,
			"milers":   r.Milers,
			"status":   r.Status,
		})
	}

	if len(response) < 3 {
		prefix := hubPincodePrefix(hubID)
		for pincode := range zoneNames {
			if strings.HasPrefix(pincode, prefix) && !seen[pincode] {
				response = append(response, fiber.Map{
					"zone":     pincode,
					"zonename": zoneName(pincode),
					"parcels":  0,
					"milers":   0,
					"status":   "Need Milers",
				})
			}
		}
	}

	return utils.List(c, response, int64(len(response)))
}

// --------------------
// NOTIFICATIONS (synthetic — no dedicated table yet)
// --------------------

type notificationEntry struct {
	Title string
	Type  string
	Time  time.Time
}

// GetHubNotifications generates notifications from real events across 4
// sources since there's no dedicated notifications table yet.
func GetHubNotifications(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	midnight := todayMidnight()
	last24h := time.Now().Add(-24 * time.Hour)

	var notifications []notificationEntry

	var exceptions []models.ConsignmentException
	db.DB.Where("hubid = ? AND createdat > ?", hubID, last24h).Find(&exceptions)
	for _, e := range exceptions {
		notifications = append(notifications, notificationEntry{
			Title: "Exception: " + e.Exceptiontype,
			Type:  "exception",
			Time:  e.Createdat,
		})
	}

	var inbound []models.Tripsheet
	db.DB.Where("destinationhubid = ? AND status IN ?", hubID, []string{constants.TripsheetDispatched, "In_Transit"}).Find(&inbound)
	for _, ts := range inbound {
		var source models.Hub
		db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source)
		t := ts.Updatedat
		if ts.Dispatchtime != nil {
			t = *ts.Dispatchtime
		}
		notifications = append(notifications, notificationEntry{
			Title: "Truck arriving from " + source.Hubname,
			Type:  "inbound",
			Time:  t,
		})
	}

	var offlineMilers []models.MilerProfile
	db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerOffline).Find(&offlineMilers)
	for _, mp := range offlineMilers {
		var hadActivityToday int64
		db.DB.Model(&models.BookingAssignment{}).Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight).Count(&hadActivityToday)
		if hadActivityToday > 0 {
			notifications = append(notifications, notificationEntry{
				Title: "Miler " + mp.Displayname + " went offline",
				Type:  "warning",
				Time:  mp.Updatedat,
			})
		}
	}

	prefix := hubPincodePrefix(hubID)
	thirtyMinAgo := time.Now().Add(-30 * time.Minute)
	var stalePending []models.PickupBooking
	staleQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ? AND createdat < ?", constants.BookingPendingPickup, thirtyMinAgo)
	if prefix != "" {
		staleQuery = staleQuery.Where("pickuppincode LIKE ?", prefix+"%")
	}
	staleQuery.Find(&stalePending)
	for _, b := range stalePending {
		notifications = append(notifications, notificationEntry{
			Title: "Pickup waiting 30+ min",
			Type:  "alert",
			Time:  b.Createdat,
		})
	}

	sort.Slice(notifications, func(i, j int) bool { return notifications[i].Time.After(notifications[j].Time) })
	if len(notifications) > 20 {
		notifications = notifications[:20]
	}

	response := make([]fiber.Map, 0, len(notifications))
	for i, n := range notifications {
		response = append(response, fiber.Map{
			"id":    i + 1,
			"title": n.Title,
			"type":  n.Type,
			"time":  humanizeRelativeTime(n.Time),
			"read":  false,
		})
	}

	return utils.List(c, response, int64(len(response)))
}

// MarkNotificationRead is a stub until a notifications table with read-state
// exists — always succeeds without persisting anything.
func MarkNotificationRead(c *fiber.Ctx) error {
	return c.JSON(fiber.Map{"success": true})
}

// --------------------
// PARCEL ROUTING (SCAN)
// --------------------

// resolveNextHop compares the delivery pincode's city prefix against the
// requesting hub's own city prefix to decide local delivery vs. transfer.
func resolveNextHop(destPrefix, myPrefix string) string {
	if destPrefix == "" || destPrefix == myPrefix {
		return "Local delivery"
	}
	var hub models.Hub
	if db.DB.Where("pincode LIKE ?", destPrefix+"%").First(&hub).Error == nil {
		return "Transfer to " + hub.Hubname
	}
	return "Transfer to Regional Hub"
}

func recommendShelfForRouting(condition string, isColdChain bool, nextHop string) string {
	switch {
	case strings.Contains(condition, "Damaged"):
		return "Exception Area"
	case isColdChain:
		return "Zone C (Cold Room)"
	case nextHop == "Local delivery":
		return "Zone B"
	default:
		return "Zone A"
	}
}

// GetRoutingInfo scans a parcel and returns its sort destination, checking
// both consignments.trackingno and pickupbookings.bookingno (a booking not
// yet converted to a consignment has no trackingno of its own).
func GetRoutingInfo(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)
	trackingNo := c.Params("trackingno")
	myPrefix := hubPincodePrefix(hubID)

	var consignment models.Consignment
	if db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error == nil {
		destPrefix := ""
		if len(consignment.Deliverypincode) >= 3 {
			destPrefix = consignment.Deliverypincode[:3]
		}
		nextHop := resolveNextHop(destPrefix, myPrefix)

		shelf := consignment.Shelf
		if shelf == "" {
			shelf = recommendShelfForRouting(consignment.Condition, false, nextHop)
		}

		return utils.OK(c, fiber.Map{
			"trackingno":       consignment.Trackingno,
			"destination":      consignment.Deliverypincode,
			"recommendedshelf": shelf,
			"nexthop":          nextHop,
			"condition":        consignment.Condition,
			"iscoldchain":      false,
			"weight":           fmt.Sprintf("%.1f kg", consignment.Chargeableweight),
			// no FK from consignments back to a customer/booking record
			"customername": "",
			"bookingid":    nil,
		})
	}

	var booking models.PickupBooking
	found := db.DB.Where("bookingno = ?", trackingNo).First(&booking).Error == nil
	if !found {
		if id, err := strconv.Atoi(trackingNo); err == nil {
			found = db.DB.Where("bookingid = ?", id).First(&booking).Error == nil
		}
	}
	if !found {
		return utils.NotFound(c, "tracking number not found")
	}

	destPrefix := ""
	if len(booking.Deliverypincode) >= 3 {
		destPrefix = booking.Deliverypincode[:3]
	}
	nextHop := resolveNextHop(destPrefix, myPrefix)
	shelf := recommendShelfForRouting("", false, nextHop)

	var customer models.AppCustomer
	db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
	customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)

	weight := "N/A"
	var parcel models.BookingParcel
	if db.DB.Where("bookingid = ?", booking.Bookingid).First(&parcel).Error == nil {
		weight = fmt.Sprintf("%.1f kg", parcel.Weight)
	}

	return utils.OK(c, fiber.Map{
		"trackingno":       booking.Bookingno,
		"destination":      fmt.Sprintf("%s, %s", booking.Deliveryaddress, booking.Deliverypincode),
		"recommendedshelf": shelf,
		"nexthop":          nextHop,
		"condition":        "Pending Scan",
		"iscoldchain":      false,
		"weight":           weight,
		"customername":     customerName,
		"bookingid":        booking.Bookingid,
	})
}

// --------------------
// RIDER ROUTES
// --------------------

// buildMilerRoute computes today's planned stops for one miler. eta_minutes
// for pending stops is a simple 15-min-per-stop placeholder until a real
// routing engine is wired up — same spirit as the in-transit interpolation.
func buildMilerRoute(mp models.MilerProfile) fiber.Map {
	var assignments []models.BookingAssignment
	db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, todayMidnight()).
		Order("assignedat ASC").Find(&assignments)

	stops := make([]fiber.Map, 0, len(assignments))
	var coords [][2]float64
	completedCount := 0
	pendingEtaMinutes := 0

	for i, a := range assignments {
		var booking models.PickupBooking
		if db.DB.Where("bookingid = ?", a.Bookingid).First(&booking).Error != nil {
			continue
		}

		itemCount := 0
		var parcel models.BookingParcel
		if db.DB.Where("bookingid = ?", a.Bookingid).First(&parcel).Error == nil {
			itemCount = 1
		}

		status := "pending"
		switch booking.Status {
		case constants.BookingPickedUp, constants.BookingConvertedConsignment:
			status = "completed"
		case constants.BookingPickupScheduled:
			status = "in_progress"
		}

		etaMinutes := 0
		if status == "completed" {
			completedCount++
		} else {
			pendingEtaMinutes += 15
			etaMinutes = pendingEtaMinutes
		}

		coords = append(coords, [2]float64{booking.Pickuplatitude, booking.Pickuplongitude})

		stop := fiber.Map{
			"seq":         i + 1,
			"address":     booking.Pickupaddress,
			"lat":         booking.Pickuplatitude,
			"lon":         booking.Pickuplongitude,
			"items":       itemCount,
			"bookingid":   booking.Bookingid,
			"status":      status,
			"eta_minutes": etaMinutes,
		}

		// Optional enrichment: only set when real data backs it, so the
		// frontend's "hide when absent" handling never sees a fake zero.
		if itemCount == 1 {
			stop["weight"] = fmt.Sprintf("%.1f kg", parcel.Weight)
		}
		var payment models.BookingPayment
		if db.DB.Where("bookingid = ?", a.Bookingid).First(&payment).Error == nil && payment.Amount > 0 {
			stop["cod"] = payment.Amount
		}
		if booking.Preferredpickupfrom != nil && booking.Preferredpickupto != nil {
			stop["timeslot"] = fmt.Sprintf("%s–%s",
				booking.Preferredpickupfrom.Format("15"), booking.Preferredpickupto.Format("15"))
		}
		if booking.Notes != "" {
			stop["instructions"] = booking.Notes
		}
		if n := len(coords); n > 1 {
			leg := haversineKM(coords[n-2][0], coords[n-2][1], coords[n-1][0], coords[n-1][1])
			stop["legdistance_km"] = math.Round(leg*10) / 10
		}

		stops = append(stops, stop)
	}

	totalDistance := 0.0
	for i := 1; i < len(coords); i++ {
		totalDistance += haversineKM(coords[i-1][0], coords[i-1][1], coords[i][0], coords[i][1])
	}

	return fiber.Map{
		"mileruserid":      mp.Userid,
		"milername":        mp.Displayname,
		"mode":             "pickup",
		"totalstops":       len(stops),
		"completedstops":   completedCount,
		"totaldistance_km": math.Round(totalDistance*10) / 10,
		"stops":            stops,
	}
}

// GetMilerRoute returns today's planned stops for a specific miler (:id is
// the mileruserid, matching bookingassignments.mileruserid).
func GetMilerRoute(c *fiber.Ctx) error {
	id, err := strconv.Atoi(c.Params("id"))
	if err != nil {
		return utils.BadRequest(c, "invalid miler id")
	}

	var mp models.MilerProfile
	if err := db.DB.Where("userid = ?", id).First(&mp).Error; err != nil {
		return utils.NotFound(c, "miler not found")
	}

	return utils.OK(c, buildMilerRoute(mp))
}

// GetAllRiderRoutes returns the same route structure for every miler at this
// hub today, for the Rider Routes overview page.
func GetAllRiderRoutes(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)

	var profiles []models.MilerProfile
	if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil {
		return utils.Internal(c, "failed to fetch milers")
	}

	routes := make([]fiber.Map, 0, len(profiles))
	for _, mp := range profiles {
		routes = append(routes, buildMilerRoute(mp))
	}

	return utils.List(c, routes, int64(len(routes)))
}

// --------------------
// HUB-SCOPED ASSIGNMENT (manual override + auto-assign)
// --------------------

// HubAssignMiler is the hub console's manual assignment override — hub staff
// (role 6) can't call POST /admin/bookings/:id/assign-miler (admin-only) or
// rely on POST /internal/bookings/:id/reassign (requires an already-assigned
// booking), so this exists for a hub-scoped path onto the same shared
// AssignMilerToBooking logic AdminAssignMiler uses.
func HubAssignMiler(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)

	bookingID, err := strconv.Atoi(c.Params("id"))
	if err != nil {
		return utils.BadRequest(c, "invalid booking id")
	}

	type AssignRequest struct {
		Mileruserid int `json:"mileruserid"`
	}
	req := new(AssignRequest)
	if err := c.BodyParser(req); err != nil {
		return utils.BadRequest(c, "invalid request body")
	}
	if req.Mileruserid == 0 {
		return utils.BadRequest(c, "mileruserid is required")
	}

	var booking models.PickupBooking
	if err := db.DB.First(&booking, bookingID).Error; err != nil {
		return utils.NotFound(c, "booking not found")
	}

	if !assignableBookingStatuses[booking.Status] {
		return utils.BadRequest(c, "booking is not in an assignable state")
	}

	var miler models.MilerProfile
	if err := db.DB.Where("userid = ? AND hubid = ?", req.Mileruserid, hubID).First(&miler).Error; err != nil {
		return utils.Forbidden(c, "miler does not belong to this hub")
	}

	// assignedbyuserid carries a real FK to appusers(userid); hub staff
	// accounts live in a separate hubstaffaccounts id space, so passing that
	// id here would violate the constraint (or worse, silently misattribute
	// to whatever appuser happens to share that id). Pass nil instead — the
	// field is nullable and this is the honest option until hub staff get a
	// mirrored appusers row.
	updatedBooking, err := AssignMilerToBooking(bookingID, req.Mileruserid, nil)
	if err != nil {
		return utils.Internal(c, "failed to assign miler")
	}

	return c.JSON(fiber.Map{
		"success": true,
		"message": "miler assigned",
		"data": fiber.Map{
			"bookingid":   updatedBooking.Bookingid,
			"mileruserid": req.Mileruserid,
			"milername":   miler.Displayname,
			"status":      updatedBooking.Status,
		},
	})
}

// HubAutoAssign triggers the AI assignment engine for one booking that wasn't
// automatically assigned at creation time. It runs TryAssignOnce (a single
// GEOSEARCH + AI-decision + commit attempt, no retry loop) in a goroutine and
// waits up to hubAutoAssignTimeout — long enough for the typical sub-second
// Redis GEO query plus the AI layer's own 5s HTTP timeout, but bounded so the
// HTTP handler never hangs indefinitely. If it doesn't finish in time, the
// goroutine keeps running in the background and the assignment still lands.
func HubAutoAssign(c *fiber.Ctx) error {
	bookingID, err := strconv.Atoi(c.Params("id"))
	if err != nil {
		return utils.BadRequest(c, "invalid booking id")
	}

	var booking models.PickupBooking
	if err := db.DB.First(&booking, bookingID).Error; err != nil {
		return utils.NotFound(c, "booking not found")
	}

	if !assignableBookingStatuses[booking.Status] {
		return utils.BadRequest(c, "booking is not in an assignable state")
	}

	type outcome struct {
		res assignment.AutoAssignResult
		err error
	}
	resultCh := make(chan outcome, 1)

	go func() {
		res, err := assignment.TryAssignOnce(bookingID)
		resultCh <- outcome{res, err}
	}()

	select {
	case out := <-resultCh:
		if out.err != nil {
			return utils.Internal(c, "assignment failed: "+out.err.Error())
		}

		if !out.res.Assigned {
			return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
				"success": false,
				"message": "No eligible miler found in range",
				"data": fiber.Map{
					"searched_radius_km": out.res.SearchedRadiusKm,
					"candidates_found":   out.res.CandidatesFound,
					"reasoning":          out.res.Reasoning,
				},
			})
		}

		return c.JSON(fiber.Map{
			"success": true,
			"data": fiber.Map{
				"mileruserid": out.res.MilerUserID,
				"milername":   out.res.MilerName,
				"distance_km": out.res.DistanceKm,
				// no confidence field: the AI decision engine's response has
				// no such value anywhere in this pipeline to report honestly.
				"reasoning": out.res.Reasoning,
			},
		})

	case <-time.After(hubAutoAssignTimeout):
		return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
			"success": true,
			"message": "assignment in progress",
		})
	}
}

// --------------------
// HUB REPORT EXPORT
// --------------------

// reportActivityLimit caps the activity timeline included in a report so a
// wide date range can't return an unbounded result set.
const reportActivityLimit = 500

// GetHubReport builds the data backing the dashboard's Export button: a
// summary plus per-section breakdowns, each filtered to [from, to] (inclusive
// day range) and scoped to the requesting hub.
func GetHubReport(c *fiber.Ctx) error {
	hubID := c.Locals("hubid").(int)

	fromStr := c.Query("from")
	toStr := c.Query("to")
	if fromStr == "" || toStr == "" {
		return utils.BadRequest(c, "from and to query params are required (YYYY-MM-DD)")
	}

	from, err := time.ParseInLocation("2006-01-02", fromStr, time.Local)
	if err != nil {
		return utils.BadRequest(c, "invalid from date, expected YYYY-MM-DD")
	}
	toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local)
	if err != nil {
		return utils.BadRequest(c, "invalid to date, expected YYYY-MM-DD")
	}
	to := toDate.Add(24*time.Hour - time.Nanosecond)
	if to.Before(from) {
		return utils.BadRequest(c, "to date must not be before from date")
	}

	// ---- summary ----
	var parcelsReceived int64
	db.DB.Model(&models.ConsignmentHistory{}).
		Where("hubid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to).
		Count(&parcelsReceived)

	var batchesDispatched int64
	db.DB.Model(&models.Tripsheet{}).
		Where("sourcehubid = ? AND dispatchtime BETWEEN ? AND ? AND deletedat IS NULL", hubID, from, to).
		Count(&batchesDispatched)

	var ordersAssigned int64
	db.DB.Model(&models.BookingAssignment{}).
		Joins("JOIN milerprofiles mp ON mp.userid = bookingassignments.mileruserid").
		Where("mp.hubid = ? AND bookingassignments.assignedat BETWEEN ? AND ?", hubID, from, to).
		Count(&ordersAssigned)

	var exceptionsCount int64
	db.DB.Model(&models.ConsignmentException{}).
		Where("hubid = ? AND createdat BETWEEN ? AND ?", hubID, from, to).
		Count(&exceptionsCount)

	var codCollected float64
	db.DB.Model(&models.BookingPayment{}).
		Joins("JOIN milerprofiles mp ON mp.userid = bookingpayments.collectedbyuserid").
		Where("mp.hubid = ? AND bookingpayments.paymentstatus = ? AND bookingpayments.createdat BETWEEN ? AND ?",
			hubID, constants.PaymentStatusPaid, from, to).
		Select("COALESCE(SUM(bookingpayments.amount), 0)").Scan(&codCollected)

	// ---- inbound: sourced from consignmenthistory, not consignments.status,
	// so a parcel that has since moved past "Inwarded_at_Hub" still shows up
	// for the day it actually arrived. ----
	var inboundHistory []models.ConsignmentHistory
	db.DB.Where("hubid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to).
		Order("createdat ASC").Find(&inboundHistory)

	inbound := make([]fiber.Map, 0, len(inboundHistory))
	for _, h := range inboundHistory {
		var cs models.Consignment
		if db.DB.Where("consignmentid = ?", h.Consignmentid).First(&cs).Error != nil {
			continue
		}
		originName := fmt.Sprintf("Direct pickup (%s)", cs.Pickuppincode)
		if cs.Originhubid != nil {
			var oh models.Hub
			if db.DB.Where("hubid = ?", *cs.Originhubid).First(&oh).Error == nil {
				originName = oh.Hubname
			}
		}
		inbound = append(inbound, fiber.Map{
			"date": h.Createdat.Format("2006-01-02"),
			// no FK from consignments back to a customer/booking record
			"bookingid":      nil,
			"trackingnumber": cs.Trackingno,
			"sendername":     "",
			"origin":         originName,
			"destination":    cs.Deliverypincode,
			"weight":         fmt.Sprintf("%.1f kg", cs.Chargeableweight),
			"condition":      cs.Condition,
			"shelf":          cs.Shelf,
			"inboundedat":    h.Createdat,
		})
	}

	// ---- dispatch ----
	var tripsheets []models.Tripsheet
	db.DB.Where("sourcehubid = ? AND dispatchtime BETWEEN ? AND ? AND deletedat IS NULL", hubID, from, to).
		Order("dispatchtime ASC").Find(&tripsheets)

	dispatch := make([]fiber.Map, 0, len(tripsheets))
	for _, ts := range tripsheets {
		var vehicleNo string
		if ts.Vehicleid != nil {
			var v models.Vehicle
			if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil {
				vehicleNo = v.Vehicleno
			}
		}
		var itemCount int64
		db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount)

		date := ""
		if ts.Dispatchtime != nil {
			date = ts.Dispatchtime.Format("2006-01-02")
		}

		dispatch = append(dispatch, fiber.Map{
			"date":         date,
			"batchlabel":   ts.Batchlabel,
			"kind":         ts.Batchkind,
			"destination":  ts.Destinationlabel,
			"vehicle":      vehicleNo,
			"itemcount":    itemCount,
			"status":       ts.Status,
			"dispatchtime": ts.Dispatchtime,
		})
	}

	// ---- assignments ----
	var assignments []models.BookingAssignment
	db.DB.Joins("JOIN milerprofiles mp ON mp.userid = bookingassignments.mileruserid").
		Where("mp.hubid = ? AND bookingassignments.assignedat BETWEEN ? AND ?", hubID, from, to).
		Order("bookingassignments.assignedat ASC").Find(&assignments)

	assignmentRows := make([]fiber.Map, 0, len(assignments))
	for _, a := range assignments {
		var booking models.PickupBooking
		if db.DB.Where("bookingid = ?", a.Bookingid).First(&booking).Error != nil {
			continue
		}
		var customer models.AppCustomer
		db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
		var miler models.MilerProfile
		db.DB.Where("userid = ?", a.Mileruserid).First(&miler)

		assignmentRows = append(assignmentRows, fiber.Map{
			"date":      a.Assignedat.Format("2006-01-02"),
			"bookingid": a.Bookingid,
			"customer":  strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
			"milername": miler.Displayname,
			"pickup":    zoneName(booking.Pickuppincode),
			"delivery":  booking.Deliverycity,
			"status":    booking.Status,
		})
	}

	// ---- exceptions ----
	var exceptions []models.ConsignmentException
	db.DB.Where("hubid = ? AND createdat BETWEEN ? AND ?", hubID, from, to).
		Order("createdat ASC").Find(&exceptions)

	exceptionRows := make([]fiber.Map, 0, len(exceptions))
	for _, ex := range exceptions {
		var cs models.Consignment
		db.DB.Where("consignmentid = ?", ex.Consignmentid).First(&cs)

		reason := ex.Description
		if reason == "" {
			reason = ex.Exceptiontype
		}

		exceptionRows = append(exceptionRows, fiber.Map{
			"date":           ex.Createdat.Format("2006-01-02"),
			"trackingnumber": cs.Trackingno,
			"reason":         reason,
			"shelf":          cs.Shelf,
		})
	}

	// ---- activity: same 4-source union as GetHubActivity, windowed to the
	// report's date range instead of "today". ----
	activityQuery := `
		(SELECT c.updatedat AS time, 'inbound' AS type,
			('Received parcel ' || c.trackingno || ' from ' || COALESCE(oh.hubname, 'Direct Pickup')) AS text
		 FROM consignments c
		 LEFT JOIN hubs oh ON c.originhubid = oh.hubid
		 WHERE c.currenthubid = ? AND c.status = ? AND c.updatedat BETWEEN ? AND ?)
		UNION ALL
		(SELECT t.dispatchtime AS time, 'dispatch' AS type,
			('Dispatched batch ' || t.tripsheetno || ' to ' || COALESCE(t.destinationlabel, '')) AS text
		 FROM tripsheets t
		 WHERE t.sourcehubid = ? AND t.dispatchtime BETWEEN ? AND ?)
		UNION ALL
		(SELECT ce.createdat AS time, 'exception' AS type,
			('Exception raised: ' || ce.exceptiontype || ' on ' || c.trackingno) AS text
		 FROM consignmentexceptions ce
		 JOIN consignments c ON ce.consignmentid = c.consignmentid
		 WHERE ce.hubid = ? AND ce.createdat BETWEEN ? AND ?)
		UNION ALL
		(SELECT ba.assignedat AS time, 'sorting' AS type,
			('Assigned booking #' || ba.bookingid || ' to miler ' || mp.displayname) AS text
		 FROM bookingassignments ba
		 JOIN milerprofiles mp ON ba.mileruserid = mp.userid
		 WHERE mp.hubid = ? AND ba.assignedat BETWEEN ? AND ?)
		ORDER BY time ASC
		LIMIT ?
	`
	var activityEntries []activityEntry
	db.DB.Raw(activityQuery,
		hubID, constants.ConsignmentInwardedAtHub, from, to,
		hubID, from, to,
		hubID, from, to,
		hubID, from, to,
		reportActivityLimit,
	).Scan(&activityEntries)

	activity := make([]fiber.Map, 0, len(activityEntries))
	for _, e := range activityEntries {
		activity = append(activity, fiber.Map{
			"time": e.Time,
			"type": e.Type,
			"text": e.Text,
		})
	}

	return utils.OK(c, fiber.Map{
		"range": fiber.Map{
			"from": fromStr,
			"to":   toStr,
		},
		"summary": fiber.Map{
			"parcels_received":   parcelsReceived,
			"batches_dispatched": batchesDispatched,
			"orders_assigned":    ordersAssigned,
			"exceptions":         exceptionsCount,
			"cod_collected":      codCollected,
		},
		"inbound":     inbound,
		"dispatch":    dispatch,
		"assignments": assignmentRows,
		"exceptions":  exceptionRows,
		"activity":    activity,
	})
}
