package service

import (
	"sort"
	"strings"
	"time"

	"github.com/tidbcloud/aisupport/services/oncall/db"
	"github.com/tidbcloud/aisupport/services/oncall/models"
)

// fixedUTC8 is the canonical display/window zone. We pin a fixed +8 offset (not
// a DST-aware zone) so window evaluation never shifts with daylight saving.
var fixedUTC8 = time.FixedZone("UTC+8", 8*3600)

// ComputedSegment is one contiguous slice of the live schedule where the set of
// on-call engineers does not change. Emails may hold multiple concurrent people.
type ComputedSegment struct {
	TeamID    uint     `json:"team_id"`
	StartTime int64    `json:"start_time"`
	EndTime   int64    `json:"end_time"`
	Emails    []string `json:"engineer_emails"`
}

// LoadTeamLayers returns a team's layers ordered by priority desc (the
// resolution order: highest priority wins when windows overlap).
func (s *ScheduleService) LoadTeamLayers(teamID uint) ([]models.TeamLayer, error) {
	var layers []models.TeamLayer
	if err := db.DB.Where("team_id = ?", teamID).
		Order("priority desc, id asc").Find(&layers).Error; err != nil {
		return nil, err
	}
	return layers, nil
}

// clampOnCall clamps a layer's OnCallAtOnce to [1, n].
func clampOnCall(k, n int) int {
	if k < 1 {
		return 1
	}
	if n > 0 && k > n {
		return n
	}
	return k
}

// gcdInt returns the greatest common divisor of a and b (both assumed >= 0).
func gcdInt(a, b int) int {
	for b != 0 {
		a, b = b, a%b
	}
	if a < 0 {
		return -a
	}
	return a
}

// floorDivInt64 is integer division rounding toward negative infinity.
func floorDivInt64(a, b int64) int64 {
	if b <= 0 {
		return 0
	}
	q := a / b
	if r := a % b; r != 0 && (r > 0) != (b > 0) {
		q--
	}
	return q
}

// dayStartUnix returns the UTC+8 midnight (as unix seconds) of the day containing at.
func dayStartUnix(at int64) int64 {
	t := time.Unix(at, 0).In(fixedUTC8)
	y, m, d := t.Date()
	return time.Date(y, m, d, 0, 0, 0, 0, fixedUTC8).Unix()
}

func intListContains(list models.IntList, v int) bool {
	for _, x := range list {
		if x == v {
			return true
		}
	}
	return false
}

// dayWindow returns the [open, close) epoch range for the window that OPENS on
// the UTC+8 day starting at dayStart. ok=false when the layer has no window that
// opens that weekday. ActiveEndMin < ActiveStartMin means the window crosses
// midnight (closes the next day); equal means the whole day.
func dayWindow(layer models.TeamLayer, dayStart int64) (open, close int64, ok bool) {
	wd := int(time.Unix(dayStart, 0).In(fixedUTC8).Weekday()) // 0=Sun..6=Sat
	if !intListContains(layer.ActiveDays, wd) {
		return 0, 0, false
	}
	start := int64(layer.ActiveStartMin)
	end := int64(layer.ActiveEndMin)
	switch {
	case end > start:
		return dayStart + start*60, dayStart + end*60, true
	case end < start:
		return dayStart + start*60, dayStart + 24*3600 + end*60, true
	default: // start == end: treat as the whole day
		return dayStart, dayStart + 24*3600, true
	}
}

// layerActiveAt reports whether the layer's window covers the instant at.
func layerActiveAt(layer models.TeamLayer, at int64) bool {
	if layer.ActiveMode != "window" {
		return true // "always" (or unset) means always active
	}
	d0 := dayStartUnix(at)
	if open, close, ok := dayWindow(layer, d0); ok && at >= open && at < close {
		return true
	}
	// tail of a window that opened the previous day and crosses midnight
	if open, close, ok := dayWindow(layer, d0-24*3600); ok && at >= open && at < close {
		return true
	}
	return false
}

// A placeholder is a rotation slot that holds no engineer: it takes its turn
// like anyone else but puts nobody on call for that shift, so an unstaffed
// period can be expressed without deleting people (which would reshuffle the
// whole cycle). The text after the colon is a free-form label, e.g.
// "placeholder:TBD"; a bare "placeholder" carries none.
const (
	placeholderToken  = "placeholder"
	placeholderPrefix = placeholderToken + ":"
)

// isPlaceholderSlot reports whether a normalized slot is an unstaffed placeholder.
func isPlaceholderSlot(slot string) bool {
	return slot == placeholderToken || strings.HasPrefix(slot, placeholderPrefix)
}

// normalizeSlot canonicalizes one entry of a layer's people list: engineers are
// normalized as emails, placeholders keep their label verbatim (only the marker
// itself is case-insensitive). Returns "" for entries to drop.
func normalizeSlot(raw string) string {
	s := strings.TrimSpace(raw)
	lower := strings.ToLower(s)
	if lower == placeholderToken {
		return placeholderToken
	}
	if strings.HasPrefix(lower, placeholderPrefix) {
		if label := strings.TrimSpace(s[len(placeholderPrefix):]); label != "" {
			return placeholderPrefix + label
		}
		return placeholderToken
	}
	return normalizeEmail(s)
}

// layerSlots returns the layer's rotation slots in order — engineers plus any
// unstaffed placeholders. Engineers are deduped; placeholders never are, since
// each one occupies its own position in the cycle.
func layerSlots(layer models.TeamLayer) []string {
	out := make([]string, 0, len(layer.People))
	seen := map[string]struct{}{}
	for _, e := range layer.People {
		e = normalizeSlot(e)
		if e == "" {
			continue
		}
		if isPlaceholderSlot(e) {
			out = append(out, e)
			continue
		}
		if _, ok := seen[e]; ok {
			continue
		}
		seen[e] = struct{}{}
		out = append(out, e)
	}
	return out
}

// layerPeople returns only the engineers of a layer, dropping placeholder slots.
// Use layerSlots wherever positions in the rotation matter.
func layerPeople(layer models.TeamLayer) []string {
	slots := layerSlots(layer)
	out := make([]string, 0, len(slots))
	for _, s := range slots {
		if !isPlaceholderSlot(s) {
			out = append(out, s)
		}
	}
	return out
}

// layerBaseIndexAt returns the index into layerSlots of the head of the on-call
// group covering the instant at (0 when the layer does not rotate). The group
// advances by OnCallAtOnce people each rotation, so consecutive periods hand off
// the whole group instead of sliding one person at a time.
func layerBaseIndexAt(layer models.TeamLayer, at int64) int {
	n := int64(len(layerSlots(layer)))
	period := layer.RotateEverySeconds
	if n == 0 || period <= 0 {
		return 0
	}
	k := int64(clampOnCall(layer.OnCallAtOnce, int(n)))
	slot := floorDivInt64(at-layer.StartsAt, period)
	base := (slot * k) % n
	return int((base + n) % n)
}

// nextBoundaryAfter returns the first rotation boundary of the layer strictly
// after at (0 when the layer does not rotate).
func nextBoundaryAfter(layer models.TeamLayer, at int64) int64 {
	period := layer.RotateEverySeconds
	if period <= 0 {
		return 0
	}
	return layer.StartsAt + (floorDivInt64(at-layer.StartsAt, period)+1)*period
}

// layerOnCallAt computes who is on call from this layer at instant at, picking
// OnCallAtOnce consecutive slots starting at the current group head. When the
// slot count is not a multiple of OnCallAtOnce the window wraps around the list,
// so every shift still covers OnCallAtOnce slots (the wrap overlaps the head).
// Placeholder slots take their turn but contribute nobody, so a group holding
// one is that much smaller — and a group of only placeholders is a no-oncall gap.
// The rotation phase runs on a continuous clock regardless of the active window.
func layerOnCallAt(layer models.TeamLayer, at int64) []string {
	slots := layerSlots(layer)
	n := len(slots)
	if n == 0 {
		return nil
	}
	k := clampOnCall(layer.OnCallAtOnce, n)
	base := layerBaseIndexAt(layer, at)
	out := make([]string, 0, k)
	for i := 0; i < k; i++ {
		if s := slots[(base+i)%n]; !isPlaceholderSlot(s) {
			out = append(out, s)
		}
	}
	return out
}

// Combine modes for how a layer stacks on lower-priority layers.
const (
	combineOverride = "override" // default: this layer seals off everything below
	combineAppend   = "append"   // union this layer's people, keep resolving below
)

// resolveLayersAt returns the on-call emails at instant at by folding the layers
// (priority desc): see foldLayersAt. Used for people-based teams. Empty = no-oncall.
func resolveLayersAt(layers []models.TeamLayer, at int64) []string {
	return foldLayersAt(layers, at, layerOnCallAt)
}

// foldLayersAt is the shared layer-combination fold. Walking priority-desc, each
// active layer contributes its on-call (via emailsAt); an "append" layer unions
// its people and lets lower layers keep contributing, while an "override" layer
// (the default) contributes and then seals off everything below. With every
// layer on the default override mode this reduces to "highest active layer wins".
// emailsAt yields a layer's on-call at the instant (people rotation for ordinary
// layers, or the referenced team's on-call for reference layers).
func foldLayersAt(layers []models.TeamLayer, at int64, emailsAt func(models.TeamLayer, int64) []string) []string {
	out := make([]string, 0)
	seen := map[string]struct{}{}
	for i := range layers { // already priority desc
		if !layerActiveAt(layers[i], at) {
			continue
		}
		for _, e := range emailsAt(layers[i], at) {
			if e == "" {
				continue
			}
			if _, ok := seen[e]; ok {
				continue
			}
			seen[e] = struct{}{}
			out = append(out, e)
		}
		if layers[i].CombineMode != combineAppend {
			break // override seals lower layers
		}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func emailsEqual(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// computeWindowFromLayers materializes (in memory, not DB) the live schedule for
// [from, to) by cutting at every rotation boundary, window edge and (for
// reference layers) upstream-team change, then merging adjacent equal segments.
// refTimelines holds each referenced team's primary timeline (prefetched by the
// caller); it is nil/empty for people-only teams.
func computeWindowFromLayers(teamID uint, layers []models.TeamLayer, from, to int64, refTimelines map[uint][]refRow) []ComputedSegment {
	if to <= from || len(layers) == 0 {
		return nil
	}

	cuts := map[int64]struct{}{from: {}, to: {}}
	add := func(t int64) {
		if t > from && t < to {
			cuts[t] = struct{}{}
		}
	}

	for _, layer := range layers {
		if layer.SourceTeamID > 0 {
			// reference layer: cut wherever the upstream team's on-call changes
			for _, r := range refTimelines[layer.SourceTeamID] {
				add(r.start)
				add(r.end)
			}
		} else if layer.RotateEverySeconds > 0 {
			// rotation boundaries
			period := layer.RotateEverySeconds
			k := floorDivInt64(from-layer.StartsAt, period)
			for {
				b := layer.StartsAt + k*period
				if b >= to {
					break
				}
				add(b)
				k++
			}
		}
		// window edges (per day across the range, plus one day of slack each side)
		if layer.ActiveMode == "window" {
			for d := dayStartUnix(from) - 24*3600; d < to+24*3600; d += 24 * 3600 {
				if open, close, ok := dayWindow(layer, d); ok {
					add(open)
					add(close)
				}
			}
		}
	}

	points := make([]int64, 0, len(cuts))
	for p := range cuts {
		points = append(points, p)
	}
	sort.Slice(points, func(i, j int) bool { return points[i] < points[j] })

	emailsAt := func(l models.TeamLayer, at int64) []string {
		if l.SourceTeamID > 0 {
			return refEmailsAt(refTimelines[l.SourceTeamID], at)
		}
		return layerOnCallAt(l, at)
	}

	segs := make([]ComputedSegment, 0)
	for i := 0; i+1 < len(points); i++ {
		a, b := points[i], points[i+1]
		if b <= a {
			continue
		}
		emails := foldLayersAt(layers, a, emailsAt)
		if len(emails) == 0 {
			continue // no-oncall gap
		}
		if len(segs) > 0 {
			last := &segs[len(segs)-1]
			if last.EndTime == a && emailsEqual(last.Emails, emails) {
				last.EndTime = b
				continue
			}
		}
		segs = append(segs, ComputedSegment{
			TeamID:    teamID,
			StartTime: a,
			EndTime:   b,
			Emails:    emails,
		})
	}
	return segs
}

// ComputeScheduleWindow returns the live (real-time) schedule for a team over
// [from, to). Pure computation from layers — nothing is written.
func (s *ScheduleService) ComputeScheduleWindow(teamID uint, from, to int64) ([]ComputedSegment, error) {
	layers, err := s.LoadTeamLayers(teamID)
	if err != nil {
		return nil, err
	}
	refTL := s.refTimelinesFor(layers, from, to)
	return computeWindowFromLayers(teamID, layers, from, to, refTL), nil
}
