package service

import (
	"errors"
	"fmt"
	"sort"
	"time"

	oncallapi "github.com/tidbcloud/aisupport/packages/sdk-go/contracts/api/oncall"
	"gorm.io/gorm"

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

// LayerInput is the write payload for one rotation layer. The rotation phase is
// never set directly: callers describe the upcoming handover (handoff_at /
// next_up_email) and the stored StartsAt anchor is derived from it, so editing
// the people list cannot accidentally reshuffle who holds which slot.
type LayerInput struct {
	// ID matches an existing layer so its handover grid carries over (and the
	// row is updated in place). Zero means a brand-new layer.
	ID       uint `json:"id"`
	Priority int  `json:"priority"`
	// People is the rotation order. An entry of "placeholder" (optionally
	// "placeholder:<label>") is an unstaffed slot: it takes its turn like anyone
	// else but puts nobody on call, so a gap can be left in the cycle without
	// removing people and reshuffling everyone else's shifts. At least one entry
	// must be a real engineer.
	People             []string `json:"people"`
	RotateEverySeconds int64    `json:"rotate_every_seconds"`
	OnCallAtOnce       int      `json:"on_call_at_once"`
	// CombineMode: "override" (default) | "append". Append unions this layer's
	// people with the lower-priority layers instead of masking them.
	CombineMode string `json:"combine_mode"`
	// SourceTeamID > 0 makes this a reference layer (its on-call is that team's
	// current primary). When set, People/rotation fields are ignored.
	SourceTeamID   uint   `json:"source_team_id"`
	ActiveMode     string `json:"active_mode"`
	ActiveDays     []int  `json:"active_days"`
	ActiveStartMin int    `json:"active_start_min"`
	ActiveEndMin   int    `json:"active_end_min"`
	// HandoffAt is an instant at which a handover occurs (usually the upcoming
	// one; any grid point works). Zero keeps the layer's current handover grid
	// (for new layers: one period from now).
	HandoffAt int64 `json:"handoff_at"`
	// NextUpEmail is who takes the rotation slot beginning at HandoffAt (a
	// "placeholder[:label]" entry may be named, to start on an unstaffed slot).
	// Empty continues the rotation from the current person's successor.
	NextUpEmail string `json:"next_up_email"`
}

// normalizeLayerWindow validates and normalizes the active-window fields shared
// by people and reference layers.
func normalizeLayerWindow(in LayerInput) (string, models.IntList, error) {
	mode := in.ActiveMode
	if mode != "window" {
		mode = "always"
	}
	days := models.IntList{}
	if mode == "window" {
		if len(in.ActiveDays) == 0 {
			return "", nil, errors.New("window layer requires active_days")
		}
		for _, d := range in.ActiveDays {
			if d < 0 || d > 6 {
				return "", nil, fmt.Errorf("active_days out of range: %d", d)
			}
			days = append(days, d)
		}
		if in.ActiveStartMin < 0 || in.ActiveStartMin > 1440 || in.ActiveEndMin < 0 || in.ActiveEndMin > 1440 {
			return "", nil, errors.New("active_start_min/active_end_min must be within 0..1440")
		}
	}
	return mode, days, nil
}

func normalizeLayerInput(teamID uint, in LayerInput) (models.TeamLayer, error) {
	combine := combineOverride
	if in.CombineMode == combineAppend {
		combine = combineAppend
	}
	mode, days, err := normalizeLayerWindow(in)
	if err != nil {
		return models.TeamLayer{}, err
	}

	// Reference layer: on-call is the referenced team's primary; People and the
	// rotation fields are ignored. Cross-layer/DB checks happen in
	// validateLayerSet once the whole set is known.
	if in.SourceTeamID > 0 {
		return models.TeamLayer{
			TeamID:         teamID,
			Priority:       in.Priority,
			SourceTeamID:   in.SourceTeamID,
			CombineMode:    combine,
			OnCallAtOnce:   1,
			ActiveMode:     mode,
			ActiveDays:     days,
			ActiveStartMin: in.ActiveStartMin,
			ActiveEndMin:   in.ActiveEndMin,
		}, nil
	}

	// People is the slot list: engineers (deduped) plus "placeholder[:label]"
	// entries, which take a turn in the rotation with nobody on call.
	slots := make(models.StringList, 0, len(in.People))
	engineers := 0
	seen := map[string]struct{}{}
	for _, raw := range in.People {
		e := normalizeSlot(raw)
		if e == "" {
			continue
		}
		if isPlaceholderSlot(e) {
			slots = append(slots, e)
			continue
		}
		if _, ok := seen[e]; ok {
			continue
		}
		seen[e] = struct{}{}
		engineers++
		slots = append(slots, e)
	}
	if engineers == 0 {
		return models.TeamLayer{}, errors.New("layer requires at least one engineer")
	}
	if len(slots) > 1 && in.RotateEverySeconds <= 0 {
		return models.TeamLayer{}, errors.New("rotate_every_seconds must be > 0 when a layer has multiple people")
	}
	k := in.OnCallAtOnce
	if k < 1 {
		k = 1
	}
	if k > len(slots) {
		k = len(slots)
	}
	return models.TeamLayer{
		TeamID:             teamID,
		Priority:           in.Priority,
		People:             slots,
		RotateEverySeconds: in.RotateEverySeconds,
		OnCallAtOnce:       k,
		CombineMode:        combine,
		ActiveMode:         mode,
		ActiveDays:         days,
		ActiveStartMin:     in.ActiveStartMin,
		ActiveEndMin:       in.ActiveEndMin,
	}, nil
}

// validateLayerSet validates reference targets: a referenced team must exist, be
// enabled, not be the team itself, and be people-only — referenced teams may not
// contain reference layers (no nesting), so resolution never recurses or cycles.
// A team may freely mix people and reference layers.
func validateLayerSet(teamID uint, layers []models.TeamLayer) error {
	for _, l := range layers {
		if l.SourceTeamID == 0 {
			continue
		}
		if l.SourceTeamID == teamID {
			return errors.New("a reference layer cannot point at its own team")
		}
		var src models.Team
		if err := db.DB.First(&src, l.SourceTeamID).Error; err != nil {
			return fmt.Errorf("referenced team %d not found", l.SourceTeamID)
		}
		if src.IsDisabled {
			return fmt.Errorf("referenced team %d is disabled", l.SourceTeamID)
		}
		var nested int64
		if err := db.DB.Model(&models.TeamLayer{}).
			Where("team_id = ? AND source_team_id > 0", l.SourceTeamID).Count(&nested).Error; err != nil {
			return err
		}
		if nested > 0 {
			return fmt.Errorf("referenced team %d is itself a reference team; nesting is not allowed", l.SourceTeamID)
		}
	}
	return nil
}

// indexOfSlot returns the position of slot in the layer's slot list, or -1.
// Engineers are unique, so only same-named placeholders can repeat; those
// resolve to the first one (see indexOfNthSlot to pick a later occurrence).
func indexOfSlot(slots []string, slot string) int {
	return indexOfNthSlot(slots, slot, 0)
}

// indexOfNthSlot returns the position of the nth (0-based) occurrence of slot,
// falling back to the first occurrence when there aren't that many, or -1 when
// the slot is absent. Duplicate placeholders share a name, so their occurrence
// number is the only thing that tells them apart.
func indexOfNthSlot(slots []string, slot string, n int) int {
	first, seen := -1, 0
	for i, p := range slots {
		if p != slot {
			continue
		}
		if first < 0 {
			first = i
		}
		if seen == n {
			return i
		}
		seen++
	}
	return first
}

// countSlot returns how many entries carry this name.
func countSlot(slots []string, slot string) int {
	n := 0
	for _, p := range slots {
		if p == slot {
			n++
		}
	}
	return n
}

// occurrenceOfSlot returns how many earlier entries share slots[i]'s name, i.e.
// which occurrence of that name it is.
func occurrenceOfSlot(slots []string, i int) int {
	n := 0
	for j := 0; j < i && j < len(slots); j++ {
		if slots[j] == slots[i] {
			n++
		}
	}
	return n
}

// stepForHeadIndex returns the rotation step count whose group head is people[want],
// snapped down to the nearest reachable head (a multiple of gcd(k,n)) when want
// sits mid-group. With the group advancing k people per step, only those heads
// are reachable; the step solves (step*k) mod n == head.
func stepForHeadIndex(n, k, want int) int {
	if n <= 0 {
		return 0
	}
	k = clampOnCall(k, n)
	g := gcdInt(k, n)
	want = ((want % n) + n) % n / g * g // normalize, then snap to a reachable head
	steps := n / g                      // distinct group heads before the cycle repeats
	for s := 0; s < steps; s++ {
		if (s*k)%n == want {
			return s
		}
	}
	return 0
}

// deriveStartsAt pins the layer's rotation phase. StartsAt is internal storage:
// with people[idx]'s group taking the slot that begins at handoff, StartsAt =
// handoff - step*period keeps every boundary on the handoff grid and makes that
// group lead from that instant onward (step is the rotation count for that head).
func deriveStartsAt(old *models.TeamLayer, in LayerInput, l *models.TeamLayer, now int64) error {
	period := l.RotateEverySeconds
	if period <= 0 {
		// Single person or everyone-together: the phase is a no-op.
		l.StartsAt = now
		return nil
	}
	handoff := in.HandoffAt
	if handoff <= 0 {
		if old != nil && old.RotateEverySeconds > 0 {
			handoff = nextBoundaryAfter(*old, now)
		} else {
			handoff = now + period
		}
	}
	idx := 0
	slots := []string(l.People)
	if e := normalizeSlot(in.NextUpEmail); e != "" {
		idx = indexOfSlot(slots, e)
		if idx < 0 {
			return fmt.Errorf("next_up_email %s is not in people", e)
		}
		// next_up_email addresses a slot by name, so it can only pin one that has
		// a unique one. Engineers always do; identically named empty slots don't.
		if n := countSlot(slots, e); n > 1 {
			return fmt.Errorf("next_up_email %s matches %d slots; give each empty slot its own label to pin one", e, n)
		}
	} else if old != nil {
		idx = defaultNextUpIndex(*old, slots, now)
	}
	step := stepForHeadIndex(len(slots), l.OnCallAtOnce, idx)
	l.StartsAt = handoff - int64(step)*period
	return nil
}

// defaultNextUpIndex picks who takes the next slot when the caller didn't say:
// the head of the group following the one on duty now (advancing a full
// OnCallAtOnce-sized group), falling back to whoever was next under the old
// rules, then to the head of the list.
func defaultNextUpIndex(old models.TeamLayer, slots []string, now int64) int {
	oldSlots := layerSlots(old)
	if len(oldSlots) == 0 || len(slots) == 0 {
		return 0
	}
	k := clampOnCall(old.OnCallAtOnce, len(oldSlots))
	base := layerBaseIndexAt(old, now)
	// Match by occurrence, not just by name: with two identically named
	// placeholders, resolving the one on duty to the first of them would silently
	// rewind the rotation on an otherwise no-op save.
	if j := indexOfNthSlot(slots, oldSlots[base], occurrenceOfSlot(oldSlots, base)); j >= 0 {
		return (j + k) % len(slots)
	}
	next := (base + k) % len(oldSlots)
	if j := indexOfNthSlot(slots, oldSlots[next], occurrenceOfSlot(oldSlots, next)); j >= 0 {
		return j
	}
	return 0
}

// normalizeTeamLayers validates the inputs and derives each layer's rotation
// anchor, matching inputs to existing layers by ID so their handover grid
// carries over. Returned layers are in resolution order (priority desc).
func normalizeTeamLayers(teamID uint, inputs []LayerInput, existing []models.TeamLayer, now int64) ([]models.TeamLayer, error) {
	oldByID := make(map[uint]*models.TeamLayer, len(existing))
	for i := range existing {
		oldByID[existing[i].ID] = &existing[i]
	}
	seen := map[uint]struct{}{}
	normalized := make([]models.TeamLayer, 0, len(inputs))
	for i, in := range inputs {
		l, err := normalizeLayerInput(teamID, in)
		if err != nil {
			return nil, fmt.Errorf("layers[%d]: %w", i, err)
		}
		var old *models.TeamLayer
		if in.ID != 0 {
			old = oldByID[in.ID]
			if old == nil {
				return nil, fmt.Errorf("layers[%d]: unknown layer id %d", i, in.ID)
			}
			if _, dup := seen[in.ID]; dup {
				return nil, fmt.Errorf("layers[%d]: duplicate layer id %d", i, in.ID)
			}
			seen[in.ID] = struct{}{}
			l.ID = in.ID
			l.CreatedAt = old.CreatedAt
		}
		if err := deriveStartsAt(old, in, &l, now); err != nil {
			return nil, fmt.Errorf("layers[%d]: %w", i, err)
		}
		normalized = append(normalized, l)
	}
	// stable order for deterministic priorities
	sort.SliceStable(normalized, func(i, j int) bool { return normalized[i].Priority > normalized[j].Priority })
	if err := validateLayerSet(teamID, normalized); err != nil {
		return nil, err
	}
	return normalized, nil
}

// GetTeamLayers returns a team's layers in resolution order (priority desc).
func (s *ScheduleService) GetTeamLayers(teamID uint) (map[string]interface{}, error) {
	if teamID == 0 {
		return nil, errors.New("missing team_id")
	}
	layers, err := s.LoadTeamLayers(teamID)
	if err != nil {
		return nil, err
	}
	var cat models.Team
	if err := db.DB.First(&cat, teamID).Error; err != nil {
		return nil, err
	}
	now := time.Now().Unix()
	items := make([]map[string]interface{}, 0, len(layers))
	for _, l := range layers {
		// The StartsAt anchor stays internal; expose the derived facts instead.
		nextHandoff := nextBoundaryAfter(l, now)
		// next_up_email is the slot taking over at the handoff — a placeholder
		// token when that slot is unstaffed, so it round-trips into a save.
		// next_up_no_oncall says the upcoming shift has nobody on call at all
		// (with on_call_at_once > 1 a placeholder head can still have company).
		nextUp := ""
		nextUpNoOncall := false
		if nextHandoff > 0 {
			if slots := layerSlots(l); len(slots) > 0 {
				nextUp = slots[layerBaseIndexAt(l, nextHandoff)]
			}
			nextUpNoOncall = len(layerOnCallAt(l, nextHandoff)) == 0
		}
		items = append(items, map[string]interface{}{
			"id":                   l.ID,
			"priority":             l.Priority,
			"people":               []string(l.People),
			"rotate_every_seconds": l.RotateEverySeconds,
			"on_call_at_once":      l.OnCallAtOnce,
			"combine_mode":         l.CombineMode,
			"source_team_id":       l.SourceTeamID,
			"active_mode":          l.ActiveMode,
			"active_days":          []int(l.ActiveDays),
			"active_start_min":     l.ActiveStartMin,
			"active_end_min":       l.ActiveEndMin,
			"next_handoff_at":      nextHandoff,
			"next_up_email":        nextUp,
			"next_up_no_oncall":    nextUpNoOncall,
		})
	}
	return map[string]interface{}{
		"team_id":        teamID,
		"backup_enabled": cat.BackupEnabled,
		"layers":         items,
	}, nil
}

// ReplaceTeamLayers replaces a team's layers declaratively: inputs carrying
// an existing layer's ID update it in place (inheriting its handover grid),
// the rest are created, and unreferenced layers are deleted. Records the prior
// schedule's history first (same tx) so this edit cannot rewrite the past or
// split the in-progress shift.
func (s *ScheduleService) ReplaceTeamLayers(teamID uint, inputs []LayerInput) (map[string]interface{}, error) {
	if teamID == 0 {
		return nil, errors.New("missing team_id")
	}
	existing, err := s.LoadTeamLayers(teamID)
	if err != nil {
		return nil, err
	}
	now := time.Now().Unix()
	normalized, err := normalizeTeamLayers(teamID, inputs, existing, now)
	if err != nil {
		return nil, err
	}

	err = db.DB.Transaction(func(tx *gorm.DB) error {
		if err := s.recordTeamHistoryTx(tx, teamID, now); err != nil {
			return err
		}
		keep := make([]uint, 0, len(normalized))
		for i := range normalized {
			if normalized[i].ID != 0 {
				keep = append(keep, normalized[i].ID)
			}
		}
		del := tx.Where("team_id = ?", teamID)
		if len(keep) > 0 {
			del = del.Where("id NOT IN ?", keep)
		}
		if err := del.Delete(&models.TeamLayer{}).Error; err != nil {
			return err
		}
		for i := range normalized {
			if err := tx.Save(&normalized[i]).Error; err != nil {
				return err
			}
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	return s.GetTeamLayers(teamID)
}

// PreviewTeamLayers dry-runs ReplaceTeamLayers: it derives exactly the layers a
// save would persist and reports the resulting schedule, without writing
// anything. New rules take effect immediately from now.
func (s *ScheduleService) PreviewTeamLayers(teamID uint, inputs []LayerInput) (map[string]interface{}, error) {
	if teamID == 0 {
		return nil, errors.New("missing team_id")
	}
	existing, err := s.LoadTeamLayers(teamID)
	if err != nil {
		return nil, err
	}
	now := time.Now().Unix()
	normalized, err := normalizeTeamLayers(teamID, inputs, existing, now)
	if err != nil {
		return nil, err
	}

	horizon := now + 28*24*3600

	// On-call at an instant for a layer set, resolving reference layers read-through.
	onCallAt := func(ls []models.TeamLayer, at int64) []string {
		tl := s.refTimelinesFor(ls, at, at+1)
		if segs := computeWindowFromLayers(teamID, ls, at, at+1, tl); len(segs) > 0 {
			return segs[0].Emails
		}
		return nil
	}

	handoff := s.teamNextBoundary(teamID, normalized, now)
	refTL := s.refTimelinesFor(normalized, now, horizon)
	segments := computeWindowFromLayers(teamID, normalized, now, horizon, refTL)

	var links []models.EngineerTeam
	if err := db.DB.Preload("Engineer").Where("team_id = ?", teamID).Find(&links).Error; err != nil {
		return nil, err
	}
	members := make(map[string]struct{}, len(links))
	for _, link := range links {
		members[normalizeEmail(link.Engineer.Email)] = struct{}{}
	}

	// Membership warnings apply to people layers only; reference layers have none.
	warnings := make([]string, 0)
	warned := map[string]struct{}{}
	inRotation := map[string]struct{}{}
	for _, l := range normalized {
		for _, e := range layerPeople(l) {
			inRotation[e] = struct{}{}
			if _, ok := members[e]; !ok {
				if _, dup := warned[e]; !dup {
					warned[e] = struct{}{}
					warnings = append(warnings, fmt.Sprintf("%s is not a member of this team", e))
				}
			}
		}
	}
	current := onCallAt(existing, now)
	for _, e := range current {
		if _, ok := inRotation[e]; !ok {
			warnings = append(warnings, fmt.Sprintf("%s is on call now and leaves the rotation; the change takes effect immediately", e))
		}
	}

	return map[string]interface{}{
		"team_id":                 teamID,
		"now":                     now,
		"handoff_at":              handoff,
		"current_engineer_emails": current,
		"next_engineer_emails":    onCallAt(normalized, handoff),
		"segments":                segments,
		"warnings":                warnings,
	}, nil
}

// GetCurrentOnCall returns everyone on call now (primary + backup), listing all
// concurrent engineers rather than picking one.
func (s *ScheduleService) GetCurrentOnCall(teamID uint, at int64) (oncallapi.CurrentOncallResponse, error) {
	if teamID == 0 {
		return oncallapi.CurrentOncallResponse{}, errors.New("missing team_id")
	}
	var team models.Team
	if err := db.DB.Select("id").Where("id = ? AND is_disabled = ?", teamID, false).First(&team).Error; err != nil {
		return oncallapi.CurrentOncallResponse{}, err
	}
	if at <= 0 {
		at = time.Now().Unix()
	}
	primary, err := s.GetEffectiveScheduleV2([]uint{teamID}, at, at+1, false, false)
	if err != nil {
		return oncallapi.CurrentOncallResponse{}, err
	}
	backup, err := s.GetEffectiveScheduleV2([]uint{teamID}, at, at+1, true, false)
	if err != nil {
		return oncallapi.CurrentOncallResponse{}, err
	}
	collect := func(rows []map[string]interface{}) []string {
		seen := map[string]struct{}{}
		out := make([]string, 0, len(rows))
		for _, r := range rows {
			e := normalizeEmail(fmt.Sprintf("%v", r["engineer_email"]))
			if e == "" {
				continue
			}
			if _, ok := seen[e]; ok {
				continue
			}
			seen[e] = struct{}{}
			out = append(out, e)
		}
		return out
	}
	managers, err := s.TeamManagerEmails(teamID)
	if err != nil {
		return oncallapi.CurrentOncallResponse{}, err
	}
	primaryEmails := collect(primary)
	layers, _ := s.LoadTeamLayers(teamID)
	return oncallapi.CurrentOncallResponse{
		TeamID:                teamID,
		AtTime:                at,
		CurrentEngineerEmails: primaryEmails,
		BackupEngineerEmails:  collect(backup),
		CurrentNoOncall:       len(primaryEmails) == 0,
		NextSwitchAt:          s.teamNextBoundary(teamID, layers, at),
		ManagerEmails:         managers,
	}, nil
}

// SetTeamBackupEnabled toggles the implicit backup lane, recording history first.
func (s *ScheduleService) SetTeamBackupEnabled(teamID uint, enabled bool) (map[string]interface{}, error) {
	if teamID == 0 {
		return nil, errors.New("missing team_id")
	}
	now := time.Now().Unix()
	err := db.DB.Transaction(func(tx *gorm.DB) error {
		if err := s.recordTeamHistoryTx(tx, teamID, now); err != nil {
			return err
		}
		return tx.Model(&models.Team{}).Where("id = ?", teamID).
			Update("backup_enabled", enabled).Error
	})
	if err != nil {
		return nil, err
	}
	return s.GetTeamLayers(teamID)
}
