package scheduler

import (
	"math"
	"math/rand"
	"sort"
	"strings"
	"time"

	"github.com/warmbly/warmbly/internal/app/behavior"
	"github.com/warmbly/warmbly/internal/models"
	"github.com/warmbly/warmbly/internal/repository"
)

// findNextValidDay finds the next valid day based on campaign days bitmask
// Bit 0 = Sunday, Bit 1 = Monday, ..., Bit 6 = Saturday
func findNextValidDay(from time.Time, daysBitmask uint8, tz *time.Location) time.Time {
	if daysBitmask == 0 {
		// If no days specified, allow all days
		return from
	}

	candidate := from.In(tz)

	// Try up to 7 days
	for i := 0; i < 7; i++ {
		dayOfWeek := int(candidate.Weekday())
		if (daysBitmask & (1 << dayOfWeek)) != 0 {
			return candidate
		}
		candidate = candidate.Add(24 * time.Hour)
	}

	// If no valid day found in 7 days, just return the input
	return from
}

// ensureTimeWindow ensures time is within the allowed window (start_time to end_time)
func ensureTimeWindow(t time.Time, startTime, endTime string, tz *time.Location) time.Time {
	start := parseTimeOfDay(startTime) // Minutes since midnight
	end := parseTimeOfDay(endTime)

	if start == 0 && end == 0 {
		// No time window specified, allow any time
		return t
	}

	tLocal := t.In(tz)
	minutesOfDay := tLocal.Hour()*60 + tLocal.Minute()

	if minutesOfDay < start {
		// Too early, move to start time today
		return time.Date(tLocal.Year(), tLocal.Month(), tLocal.Day(),
			start/60, start%60, 0, 0, tz)
	}

	if minutesOfDay > end {
		// Too late, move to tomorrow's start time
		next := tLocal.Add(24 * time.Hour)
		return time.Date(next.Year(), next.Month(), next.Day(),
			start/60, start%60, 0, 0, tz)
	}

	return t
}

// effectiveWindows returns the campaign's authoritative per-day sending
// schedule. When ScheduleWindows is set it is used as-is; otherwise it is
// DERIVED from the legacy days bitmask + start/end time (one interval per active
// day) so campaigns created before the multi-window feature still schedule
// correctly. The stored days bitmask is Monday-indexed (bit 0 = Monday, as the
// bitmask package and dashboard write it), so it is mapped to time.Weekday
// (Sun=0) via (bit+1)%7 — which also corrects the historical off-by-one in the
// legacy day check.
func effectiveWindows(c *models.Campaign) models.ScheduleWindows {
	if !c.ScheduleWindows.IsEmpty() {
		return c.ScheduleWindows
	}
	start := parseTimeOfDay(c.StartTime)
	end := parseTimeOfDay(c.EndTime)
	if end <= start {
		// No usable legacy window → unconstrained (any time allowed).
		return models.ScheduleWindows{}
	}
	var sw models.ScheduleWindows
	for bit := 0; bit < 7; bit++ {
		if c.Days == 0 || c.Days&(1<<uint(bit)) != 0 {
			wd := (bit + 1) % 7 // Monday-indexed bit → time.Weekday
			sw[wd] = []models.TimeInterval{{Start: start, End: end}}
		}
	}
	return sw
}

// nextScheduleSlot returns the earliest time >= from that falls inside one of
// the campaign's per-day sending windows, searching up to 8 days ahead. An
// empty schedule means "unconstrained" and returns from unchanged. When from is
// already inside a window its exact instant is preserved (so jitter/min-wait
// adjustments survive); otherwise it advances to the next interval's start.
func nextScheduleSlot(from time.Time, sw models.ScheduleWindows, tz *time.Location) time.Time {
	if sw.IsEmpty() {
		return from
	}
	cur := from.In(tz)
	for i := 0; i < 8; i++ {
		y, m, d := cur.Date()
		nowMin := cur.Hour()*60 + cur.Minute()

		ivs := append([]models.TimeInterval(nil), sw[int(cur.Weekday())]...)
		sort.Slice(ivs, func(a, b int) bool { return ivs[a].Start < ivs[b].Start })

		for _, iv := range ivs {
			if nowMin < iv.Start {
				return time.Date(y, m, d, iv.Start/60, iv.Start%60, 0, 0, tz)
			}
			if nowMin < iv.End {
				if i == 0 {
					return from // already inside a window — keep the exact instant
				}
				return cur
			}
		}
		// No interval left today — jump to the start of the next day.
		cur = time.Date(y, m, d, 0, 0, 0, 0, tz).Add(24 * time.Hour)
	}
	return from
}

// ensureBusinessHours ensures time is within business hours (8am-8pm)
func ensureBusinessHours(t time.Time, timezone string) time.Time {
	loc := loadLocation(timezone)
	return ensureTimeWindow(t, "08:00", "20:00", loc)
}

// businessHoursReopen is when the 8am-8pm band next opens for an instant
// that sits outside it: 8am the same local day before the band, 8am the next
// local day after it.
func businessHoursReopen(t time.Time, loc *time.Location) time.Time {
	local := t.In(loc)
	open := time.Date(local.Year(), local.Month(), local.Day(), 8, 0, 0, 0, loc)
	if local.Hour() >= 20 {
		open = open.AddDate(0, 0, 1)
	}
	return open
}

// calculateHoursRemainingUntil calculates hours remaining until a specific end time
func calculateHoursRemainingUntil(timezone, endTime string) float64 {
	loc := loadLocation(timezone)
	now := time.Now().In(loc)
	endMinutes := parseTimeOfDay(endTime)
	if endMinutes == 0 {
		endMinutes = 20 * 60 // fallback to 8pm
	}
	endOfDay := time.Date(now.Year(), now.Month(), now.Day(), endMinutes/60, endMinutes%60, 0, 0, loc)
	if now.After(endOfDay) {
		return 0
	}
	return max(0, endOfDay.Sub(now).Hours())
}

// calculateFirstSlotTomorrowAt calculates first slot tomorrow at a specific start time
func calculateFirstSlotTomorrowAt(timezone, startTime string) time.Time {
	loc := loadLocation(timezone)
	now := time.Now().In(loc)
	startMinutes := parseTimeOfDay(startTime)
	if startMinutes == 0 {
		startMinutes = 8 * 60 // fallback to 8am
	}
	tomorrow := now.Add(24 * time.Hour)
	firstSlot := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(),
		startMinutes/60, startMinutes%60, 0, 0, loc)
	jitter := randomJitter(0, 60)
	return finalSlot(firstSlot.Add(time.Minute * time.Duration(jitter)))
}

// humanizeSeconds randomises the sub-minute component of a scheduled time. All
// jitter above is minute-granular and every time.Date construction lands on
// second :00, so without this the whole platform sends at second zero — a
// fleet-wide fingerprint in Received headers. Applied as the last step of the
// schedulers.
//
// It truncates DOWN to the minute before re-randomising, so on its own it can
// move a slot up to 59 seconds EARLIER. Callers that are producing a real
// scheduled_at must therefore use finalSlot, which clamps afterwards; this
// helper deliberately stays a pure formatter.
func humanizeSeconds(t time.Time) time.Time {
	return t.Truncate(time.Minute).Add(time.Duration(rand.Intn(60)) * time.Second)
}

// finalSlot is the last thing a scheduler does to a candidate: randomise its
// sub-minute component, then guarantee the result is still in the future.
//
// Both halves matter. Without the randomisation the whole fleet sends at second
// :00, which is a fingerprint in Received headers. Without the clamp, that same
// randomisation — plus the symmetric jitter each scheduler applies — can land a
// near-term send in the past, where it fires immediately with none of the
// spacing it was placed with, or gets cancelled outright by the overdue sweep.
func finalSlot(t time.Time) time.Time {
	return notBefore(humanizeSeconds(t))
}

// avoidRoundTimes adds randomness to avoid exact round times (10:00, 11:00)
func avoidRoundTimes(t time.Time) time.Time {
	if t.Minute() == 0 {
		// Move to random minute between 3-12
		offset := randomJitter(3, 12)
		return t.Add(time.Minute * time.Duration(offset))
	}
	return t
}

// applyDistributionCurve applies human-like distribution patterns
// Favors morning (9-11am) and afternoon (2-4pm) peaks
func applyDistributionCurve(t time.Time, tz *time.Location) time.Time {
	hour := t.In(tz).Hour()

	// Avoid lunch hour (12-1pm) - 30% chance to push to 1:15pm
	if hour == 12 {
		if rand.Float64() < 0.3 {
			minutes := 75 + randomJitter(0, 30)
			return t.Add(time.Minute * time.Duration(minutes))
		}
	}

	// Slightly avoid very early (before 9am) and very late (after 6pm)
	// Add small random delays to push toward peak hours
	if hour < 9 {
		// Small chance to push to 9am
		if rand.Float64() < 0.2 {
			target := time.Date(t.Year(), t.Month(), t.Day(), 9, 0, 0, 0, tz)
			if target.After(t) {
				offset := randomJitter(0, 30)
				return target.Add(time.Minute * time.Duration(offset))
			}
		}
	}

	return t
}

// resolveConflicts resolves scheduling conflicts with existing tasks
// Ensures minimum spacing between emails from the same account
func resolveConflicts(desired time.Time, scheduled []repository.Task, minWait int) time.Time {
	if len(scheduled) == 0 {
		return desired
	}

	// Sort tasks by scheduled time
	sort.Slice(scheduled, func(i, j int) bool {
		if scheduled[i].ScheduledAt == nil || scheduled[j].ScheduledAt == nil {
			return false
		}
		return scheduled[i].ScheduledAt.Before(*scheduled[j].ScheduledAt)
	})

	candidate := desired
	maxAttempts := 100

	for attempt := 0; attempt < maxAttempts; attempt++ {
		hasConflict := false

		for _, task := range scheduled {
			if task.ScheduledAt == nil {
				continue
			}

			diff := math.Abs(candidate.Sub(*task.ScheduledAt).Seconds())

			if diff < float64(minWait) {
				// Conflict! Move candidate after this task
				hasConflict = true
				candidate = task.ScheduledAt.Add(time.Second * time.Duration(minWait))

				// Add small random jitter to avoid creating a new conflict
				jitterMinutes := randomJitter(1, 5)
				candidate = candidate.Add(time.Minute * time.Duration(jitterMinutes))
				break
			}
		}

		if !hasConflict {
			return candidate
		}
	}

	// If still conflicts after 100 attempts, push to next hour
	return candidate.Add(time.Hour)
}

// AccountCandidate holds an email account with its computed scheduling weight
type AccountCandidate struct {
	Account        models.Email
	RemainingToday int
	WarmupAgeDays  int
	Weight         float64

	// Per-sender rotation metadata (explicit sender strategy only). SenderWeight
	// multiplies the base Weight in weighted mode; RotationPosition drives
	// round_robin; SenderLastSentAt drives least_recently_used. Defaults
	// (weight 1, nil last-sent, position 0) make tag-strategy candidates behave
	// exactly as before.
	SenderWeight      int
	RotationPosition  int
	SenderLastSentAt  *time.Time
	HasSenderMetadata bool

	// ProviderMatch reflects whether this mailbox's provider matches the
	// recipient ESP under ESP matching. Always true when ESP matching is off or
	// the recipient provider is unknown.
	ProviderMatch bool

	// Behavior is the mailbox's resolved sending-behaviour profile for this
	// pass. Zero value (Enabled false) means the mailbox has not opted in.
	Behavior behavior.Resolved
	// BehaviorOpenAt is the earliest instant the mailbox's rolled workday and
	// hourly ceiling allow, computed while filtering candidates. nil when
	// behaviour is off for this mailbox.
	BehaviorOpenAt *time.Time
}

// remainingSendMinutes returns how much sending time is left in the day the
// candidate is about to send on, which is what the even-distribution step
// paces the day's remaining emails across.
//
// With a behaviour profile the answer comes from the mailbox's own rolled
// workday in its own timezone, with any part of the lunch break still ahead
// subtracted — pacing across a window that includes a break the mailbox will
// not send in would bunch the remainder into the afternoon. Without one it is
// the campaign's window span for that weekday, exactly as before.
func remainingSendMinutes(c *AccountCandidate, at time.Time, sw models.ScheduleWindows, campaignTZ *time.Location) (int, bool) {
	if c.Behavior.Enabled {
		loc := c.Behavior.Loc
		plan := c.Behavior.PlanOn(behavior.PlanDateFor(at, loc))
		if !plan.IsWorkingDay {
			return 0, false
		}
		cur := max(behavior.MinuteOfDay(at, loc), plan.WorkStartMinute)
		if cur >= plan.WorkEndMinute {
			return 0, false
		}
		remaining := plan.WorkEndMinute - cur
		if plan.HasLunch() {
			breakStart, breakEnd := *plan.LunchStartMinute, *plan.LunchEndMinute
			if breakEnd > cur {
				remaining -= breakEnd - max(breakStart, cur)
			}
		}
		return max(remaining, 0), true
	}

	wd := int(at.In(campaignTZ).Weekday())
	dayStart, dayEnd, ok := sw.DaySpan(wd)
	if !ok {
		return 0, false
	}
	nowLocal := time.Now().In(campaignTZ)
	currentMinutes := nowLocal.Hour()*60 + nowLocal.Minute()
	return dayEnd - max(currentMinutes, dayStart), true
}

// poolRemainingOn is how many cold sends the campaign's eligible mailboxes still
// have between them on the day `at` falls on — the denominator the even-
// distribution step paces the day across.
//
// Only mailboxes actually landing on that day are counted. A behaviour-profiled
// mailbox whose today is spent has already been walked to a later day by
// placeWithinBehavior, and counting tomorrow's allowance as if it were available
// now would pace the campaign faster than the mailboxes that can send today can
// keep up with. A mailbox that opened BEFORE `at` is available whatever day it
// opened on, which is what keeps the count right when the whole pass has been
// pushed to tomorrow because every mailbox was at capacity today.
func poolRemainingOn(pool []AccountCandidate, at time.Time) int {
	total := 0
	for i := range pool {
		c := &pool[i]
		if c.RemainingToday <= 0 {
			continue
		}
		if c.Behavior.Enabled && c.BehaviorOpenAt != nil &&
			c.BehaviorOpenAt.After(at) && !sameLocalDay(*c.BehaviorOpenAt, at, c.Behavior.Loc) {
			continue
		}
		total += c.RemainingToday
	}
	return total
}

// campaignRampCeiling returns the day's effective ramp ceiling. When ramp is
// disabled it returns the campaign ceiling unchanged (the caller still min()s
// against the per-mailbox cap). When enabled it returns the already-advanced
// level clamped into [start, ceiling]. The scheduler applies this ONLY via
// min() against the per-mailbox cold cap, so it can only LOWER volume.
func campaignRampCeiling(enabled bool, start, increment, ceiling, level int) int {
	_ = increment // the increment is applied by AdvanceRampLevel; level is already advanced
	if !enabled {
		return ceiling
	}
	v := level
	if v < start {
		v = start
	}
	if v > ceiling {
		v = ceiling
	}
	return v
}

// providerForEmailDomain maps a recipient email address to a coarse ESP bucket
// for provider matching. Pure string work — never dials MX. Unknown/other
// domains return "" so matching never blocks the first contact.
func providerForEmailDomain(email string) string {
	at := strings.LastIndex(email, "@")
	if at < 0 || at == len(email)-1 {
		return ""
	}
	domain := strings.ToLower(strings.TrimSpace(email[at+1:]))
	switch domain {
	case "gmail.com", "googlemail.com":
		return "gmail"
	case "outlook.com", "hotmail.com", "live.com", "msn.com", "office365.com", "microsoft.com":
		return "outlook"
	}
	// Subdomain / suffix heuristics for hosted Google/Microsoft mail.
	if strings.HasSuffix(domain, ".onmicrosoft.com") {
		return "outlook"
	}
	return ""
}

// computeWeight calculates a scheduling weight for an account based on remaining capacity and warmup age.
// Accounts with more remaining capacity and older warmup age get higher weight.
func computeWeight(remaining int, warmupAgeDays int) float64 {
	if remaining <= 0 {
		return 0
	}
	warmupFactor := 1.0 + math.Log2(float64(warmupAgeDays+1))
	return float64(remaining) * warmupFactor
}

// selectAccountWeighted picks an account using weighted random selection.
// Returns nil if all candidates have zero weight. The per-sender weight (1..100,
// default 1 for tag-strategy candidates) multiplies the base scheduling weight,
// so an operator can bias rotation toward specific mailboxes without ever
// raising any mailbox above its already-clamped per-mailbox cap.
func selectAccountWeighted(candidates []AccountCandidate) *AccountCandidate {
	var totalWeight float64
	var viable []AccountCandidate
	for _, c := range candidates {
		w := effectiveWeight(c)
		if w > 0 {
			totalWeight += w
			c.Weight = w
			viable = append(viable, c)
		}
	}

	if len(viable) == 0 {
		return nil
	}

	r := rand.Float64() * totalWeight
	var cumulative float64
	for i := range viable {
		cumulative += viable[i].Weight
		if r <= cumulative {
			return &viable[i]
		}
	}

	// Fallback to last viable candidate
	return &viable[len(viable)-1]
}

// effectiveWeight folds the per-sender weight into the base scheduling weight.
// Tag-strategy candidates carry SenderWeight 0 (no metadata) and so use the
// base weight unchanged.
func effectiveWeight(c AccountCandidate) float64 {
	if c.HasSenderMetadata && c.SenderWeight > 0 {
		return c.Weight * float64(c.SenderWeight)
	}
	return c.Weight
}

// selectAccountLeastRecentlyUsed picks the viable candidate (Weight>0) whose
// sender last_sent_at is oldest; a nil last_sent_at (never used) sorts first.
// Ties broken by account id for determinism.
func selectAccountLeastRecentlyUsed(candidates []AccountCandidate) *AccountCandidate {
	var best *AccountCandidate
	for i := range candidates {
		if candidates[i].Weight <= 0 {
			continue
		}
		if best == nil || lruLess(&candidates[i], best) {
			best = &candidates[i]
		}
	}
	return best
}

func lruLess(a, b *AccountCandidate) bool {
	switch {
	case a.SenderLastSentAt == nil && b.SenderLastSentAt == nil:
		return a.Account.ID.String() < b.Account.ID.String()
	case a.SenderLastSentAt == nil:
		return true
	case b.SenderLastSentAt == nil:
		return false
	case a.SenderLastSentAt.Equal(*b.SenderLastSentAt):
		return a.Account.ID.String() < b.Account.ID.String()
	default:
		return a.SenderLastSentAt.Before(*b.SenderLastSentAt)
	}
}

// selectAccountRoundRobin picks the viable candidate (Weight>0) with the lowest
// rotation_position; ties broken by account id for determinism.
func selectAccountRoundRobin(candidates []AccountCandidate) *AccountCandidate {
	var best *AccountCandidate
	for i := range candidates {
		if candidates[i].Weight <= 0 {
			continue
		}
		if best == nil ||
			candidates[i].RotationPosition < best.RotationPosition ||
			(candidates[i].RotationPosition == best.RotationPosition &&
				candidates[i].Account.ID.String() < best.Account.ID.String()) {
			best = &candidates[i]
		}
	}
	return best
}

// needsRotationFallback reports whether a rotation mode depends on per-sender
// bookkeeping that only explicitly-picked senders carry. Weighted selection is
// driven by remaining capacity, so it already spreads without a cursor.
func needsRotationFallback(rotationMode string) bool {
	return rotationMode == "round_robin" || rotationMode == "least_recently_used"
}

// selectAccountByRotationMode dispatches to the chosen rotation strategy. For
// explicit-strategy campaigns rotationMode is one of weighted / round_robin /
// least_recently_used; anything else falls back to weighted.
func selectAccountByRotationMode(rotationMode string, candidates []AccountCandidate) *AccountCandidate {
	switch rotationMode {
	case "round_robin":
		return selectAccountRoundRobin(candidates)
	case "least_recently_used":
		return selectAccountLeastRecentlyUsed(candidates)
	default:
		return selectAccountWeighted(candidates)
	}
}
