package service

import (
	"errors"
	"fmt"
	"log/slog"
	"os"
	"strings"
	"time"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"

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

const (
	weeklyRosterKind = "weekly_roster"
	// weeklyRosterLiveKind is a separate, independent notification: a "who's on
	// call right now" card sent the day after the full roster (Friday, once the
	// new week's schedule has actually started) and live-patched thereafter.
	weeklyRosterLiveKind = "weekly_roster_live"
	// Exactly-once is guaranteed by the idempotency log, not the tick cadence.
	weeklyRosterTickInterval = 30 * time.Minute
	// How late we may still send: absorbs tick jitter/short outages but skips a
	// stale post after a long downtime.
	weeklyRosterGrace = 6 * time.Hour
)

var weeklyRosterLoc = time.FixedZone("UTC+8", 8*3600)

// weeklyRosterDue returns the scheduled send instant (this week's Thursday 11:00
// UTC+8) and whether now is at/after it but still within the grace window.
func weeklyRosterDue(now time.Time) (sendAt time.Time, due bool) {
	nowLoc := now.In(weeklyRosterLoc)
	candidate := time.Date(nowLoc.Year(), nowLoc.Month(), nowLoc.Day(), 11, 0, 0, 0, weeklyRosterLoc)
	daysSinceThursday := (int(nowLoc.Weekday()) - int(time.Thursday) + 7) % 7
	candidate = candidate.AddDate(0, 0, -daysSinceThursday)
	if candidate.After(nowLoc) {
		// Thursday but before 11:00 → the latest Thursday 11:00 was a week ago.
		candidate = candidate.AddDate(0, 0, -7)
	}
	delta := nowLoc.Sub(candidate)
	return candidate, delta >= 0 && delta <= weeklyRosterGrace
}

// weeklyRosterLiveDue returns the scheduled live-card send instant (this
// week's Friday 11:05 UTC+8 — 5 minutes after the roster's own [from, to)
// window starts, so the send doesn't race the exact handover boundary) and
// whether now is at/after it but still within the grace window.
func weeklyRosterLiveDue(now time.Time) (sendAt time.Time, due bool) {
	nowLoc := now.In(weeklyRosterLoc)
	candidate := time.Date(nowLoc.Year(), nowLoc.Month(), nowLoc.Day(), 11, 5, 0, 0, weeklyRosterLoc)
	daysSinceFriday := (int(nowLoc.Weekday()) - int(time.Friday) + 7) % 7
	candidate = candidate.AddDate(0, 0, -daysSinceFriday)
	if candidate.After(nowLoc) {
		candidate = candidate.AddDate(0, 0, -7)
	}
	delta := nowLoc.Sub(candidate)
	return candidate, delta >= 0 && delta <= weeklyRosterGrace
}

func getWeeklyChatID() string {
	return strings.TrimSpace(os.Getenv("ONCALL_WEEKLY_CHAT_ID"))
}

// WeeklyNotifyEnabled reports whether the weekly roster loop should run (off by default).
func WeeklyNotifyEnabled() bool {
	switch strings.ToLower(strings.TrimSpace(os.Getenv("ONCALL_NOTIFY_ENABLED"))) {
	case "1", "true", "yes", "on":
		return true
	}
	return false
}

// RunWeeklyRosterIfDue sends the weekly on-call roster as a plain-text Feishu
// message to the configured group chat when due, exactly once per week across
// ticks/restarts/replicas. This announcement is not live-patched — the
// separate "who's on call right now" card (RunWeeklyRosterLiveIfDue) sent the
// following day is what stays live-updated during the week.
func (s *ScheduleService) RunWeeklyRosterIfDue(now time.Time) error {
	return s.runWeeklyRosterIfDue(now, getWeeklyChatID(), SendFeishuGroupMessage)
}

func (s *ScheduleService) runWeeklyRosterIfDue(now time.Time, chatID string, send func(chatID, msg string) error) error {
	sendAt, due := weeklyRosterDue(now)
	if !due {
		return nil
	}

	// Claim this week's slot first; the unique (kind, ref_time) index makes it
	// atomic across replicas (only the winner gets RowsAffected == 1).
	row := models.NotificationLog{Kind: weeklyRosterKind, RefTime: sendAt.Unix(), SentAt: now.Unix()}
	res := db.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
	if res.Error != nil {
		return res.Error
	}
	if res.RowsAffected == 0 {
		return nil // already sent this week, or another replica is sending it
	}

	if chatID == "" {
		s.releaseWeeklyClaim(sendAt)
		return fmt.Errorf("ONCALL_WEEKLY_CHAT_ID not set")
	}

	// Window: Friday 11:00 (send instant + 1 day) through the following Friday 11:00.
	from := sendAt.AddDate(0, 0, 1).Unix()
	to := sendAt.AddDate(0, 0, 8).Unix()

	msg, err := s.BuildWeeklyRosterMessage(from, to)
	if err != nil {
		s.releaseWeeklyClaim(sendAt)
		return err
	}
	if err := send(chatID, msg); err != nil {
		s.releaseWeeklyClaim(sendAt) // let a later tick within the grace window retry
		return err
	}
	return nil
}

// refreshWeeklyRosterCard re-renders the currently-active week's roster and
// patches the previously-sent card in place if the content changed. It never
// sends a new message and never touches the send-side claim/release logic
// above; replicas racing here are harmless since a patch is just an idempotent
// re-render of the same underlying schedule data.
func (s *ScheduleService) refreshWeeklyRosterCard(now time.Time) error {
	return s.refreshWeeklyRosterCardWithPatch(now, PatchFeishuCardMessage)
}

func (s *ScheduleService) refreshWeeklyRosterCardWithPatch(now time.Time, patch func(messageID string, card map[string]interface{}) error) error {
	var row models.NotificationLog
	err := db.DB.Where("kind = ?", weeklyRosterKind).Order("ref_time DESC").First(&row).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil // nothing sent yet
	}
	if err != nil {
		return err
	}
	if row.MessageID == nil || *row.MessageID == "" {
		return nil // pre-migration row, or send succeeded but persisting the ID failed
	}

	sendAt := time.Unix(row.RefTime, 0)
	from := sendAt.AddDate(0, 0, 1).Unix()
	to := sendAt.AddDate(0, 0, 8).Unix()
	if nowUnix := now.Unix(); nowUnix < from || nowUnix >= to+int64(weeklyRosterGrace/time.Second) {
		return nil // this row's week is no longer the current one
	}

	card, hash, err := s.BuildWeeklyRosterCard(from, to)
	if err != nil {
		return err
	}
	if row.ContentHash != nil && *row.ContentHash == hash {
		return nil // unchanged since last send/patch
	}

	if err := patch(*row.MessageID, card); err != nil {
		slog.Warn("failed to patch weekly roster card; will retry next tick", "ref_time", row.RefTime, "message_id", *row.MessageID, "error", err)
		return nil
	}

	patchedAt := now.Unix()
	return db.DB.Model(&models.NotificationLog{}).Where("id = ?", row.ID).
		Updates(map[string]interface{}{"content_hash": hash, "patched_at": patchedAt}).Error
}

// releaseWeeklyClaim drops a claimed-but-unsent slot so a later tick can retry.
func (s *ScheduleService) releaseWeeklyClaim(sendAt time.Time) {
	if err := db.DB.Where("kind = ? AND ref_time = ?", weeklyRosterKind, sendAt.Unix()).
		Delete(&models.NotificationLog{}).Error; err != nil {
		slog.Warn("failed to release weekly roster claim", "ref_time", sendAt.Unix(), "error", err)
	}
}

// RunWeeklyRosterLiveIfDue sends the "who's on call right now" card when due
// (exactly once per week, the Friday after the full roster goes out), then
// refreshes it in place every tick thereafter as shifts change hands during
// the week. Fully independent of RunWeeklyRosterIfDue/weeklyRosterKind above:
// separate NotificationLog kind, separate claim, separate message.
func (s *ScheduleService) RunWeeklyRosterLiveIfDue(now time.Time) error {
	if err := s.runWeeklyRosterLiveIfDue(now, getWeeklyChatID(), SendFeishuGroupCard); err != nil {
		return err
	}
	return s.refreshWeeklyRosterLiveCard(now)
}

func (s *ScheduleService) runWeeklyRosterLiveIfDue(now time.Time, chatID string, send func(chatID string, card map[string]interface{}) (string, error)) error {
	sendAt, due := weeklyRosterLiveDue(now)
	if !due {
		return nil
	}

	row := models.NotificationLog{Kind: weeklyRosterLiveKind, RefTime: sendAt.Unix(), SentAt: now.Unix()}
	res := db.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
	if res.Error != nil {
		return res.Error
	}
	if res.RowsAffected == 0 {
		return nil // already sent this week, or another replica is sending it
	}

	if chatID == "" {
		s.releaseWeeklyRosterLiveClaim(sendAt)
		return fmt.Errorf("ONCALL_WEEKLY_CHAT_ID not set")
	}

	// Window: same as the full roster's — Friday 11:00 (5 minutes before this
	// send instant) through the following Friday 11:00.
	from := sendAt.Add(-5 * time.Minute).Unix()
	to := from + 7*24*3600

	card, hash, err := s.BuildWeeklyRosterLiveCard(from, to, now.Unix())
	if err != nil {
		s.releaseWeeklyRosterLiveClaim(sendAt)
		return err
	}
	messageID, err := send(chatID, card)
	if err != nil {
		s.releaseWeeklyRosterLiveClaim(sendAt)
		return err
	}

	if err := db.DB.Model(&models.NotificationLog{}).Where("id = ?", row.ID).
		Updates(map[string]interface{}{"message_id": messageID, "content_hash": hash}).Error; err != nil {
		slog.Error("failed to persist weekly roster live card message id; card will not be live-patched this week", "ref_time", sendAt.Unix(), "error", err)
	}
	return nil
}

// refreshWeeklyRosterLiveCard re-renders "who's on call right now" for the
// currently-active week and patches the previously-sent live card in place if
// it changed — either because the schedule changed, or simply because time
// moved on and a shift handover occurred. Never sends a new message.
func (s *ScheduleService) refreshWeeklyRosterLiveCard(now time.Time) error {
	return s.refreshWeeklyRosterLiveCardWithPatch(now, PatchFeishuCardMessage)
}

func (s *ScheduleService) refreshWeeklyRosterLiveCardWithPatch(now time.Time, patch func(messageID string, card map[string]interface{}) error) error {
	var row models.NotificationLog
	err := db.DB.Where("kind = ?", weeklyRosterLiveKind).Order("ref_time DESC").First(&row).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil // nothing sent yet
	}
	if err != nil {
		return err
	}
	if row.MessageID == nil || *row.MessageID == "" {
		return nil // pre-migration row, or send succeeded but persisting the ID failed
	}

	sendAt := time.Unix(row.RefTime, 0)
	from := sendAt.Add(-5 * time.Minute).Unix()
	to := from + 7*24*3600
	if nowUnix := now.Unix(); nowUnix < from || nowUnix >= to+int64(weeklyRosterGrace/time.Second) {
		return nil // this row's week is no longer the current one
	}

	card, hash, err := s.BuildWeeklyRosterLiveCard(from, to, now.Unix())
	if err != nil {
		return err
	}
	if row.ContentHash != nil && *row.ContentHash == hash {
		return nil // unchanged since last send/patch
	}

	if err := patch(*row.MessageID, card); err != nil {
		slog.Warn("failed to patch weekly roster live card; will retry next tick", "ref_time", row.RefTime, "message_id", *row.MessageID, "error", err)
		return nil
	}

	patchedAt := now.Unix()
	return db.DB.Model(&models.NotificationLog{}).Where("id = ?", row.ID).
		Updates(map[string]interface{}{"content_hash": hash, "patched_at": patchedAt}).Error
}

// releaseWeeklyRosterLiveClaim drops a claimed-but-unsent live-card slot so a
// later tick can retry.
func (s *ScheduleService) releaseWeeklyRosterLiveClaim(sendAt time.Time) {
	if err := db.DB.Where("kind = ? AND ref_time = ?", weeklyRosterLiveKind, sendAt.Unix()).
		Delete(&models.NotificationLog{}).Error; err != nil {
		slog.Warn("failed to release weekly roster live claim", "ref_time", sendAt.Unix(), "error", err)
	}
}

// StartWeeklyNotifyLoop tick-checks the weekly roster until process exit; run in its own goroutine.
func StartWeeklyNotifyLoop() {
	slog.Info("weekly on-call roster loop started", "interval", weeklyRosterTickInterval.String())
	s := NewScheduleService()
	tick := func() {
		if err := s.RunWeeklyRosterIfDue(time.Now()); err != nil {
			slog.Error("weekly on-call roster tick failed", "error", err)
		}
		if err := s.RunWeeklyRosterLiveIfDue(time.Now()); err != nil {
			slog.Error("weekly on-call roster live tick failed", "error", err)
		}
	}
	tick()
	ticker := time.NewTicker(weeklyRosterTickInterval)
	defer ticker.Stop()
	for range ticker.C {
		tick()
	}
}
