package service

import (
	"context"
	"errors"
	"strings"
	"time"

	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/gitstore"

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

const (
	mergeGroupLifecycleChecksRequested = "checks_requested"
	mergeGroupLifecycleDestroyed       = "destroyed"

	mergeGroupDestroyedMerged      = "merged"
	mergeGroupDestroyedDequeued    = "dequeued"
	mergeGroupDestroyedInvalidated = "invalidated"
)

// publishMergeGroup makes the database row durable before publishing the Git
// ref. A crash on either side of UpdateRef leaves a recoverable publishing_ref
// row instead of an untracked orphan ref.
func (s *Service) publishMergeGroup(ctx context.Context, group db.MergeGroup, actor db.User) error {
	gitStore, err := s.GitForCtx(ctx)
	if err != nil {
		return err
	}
	shouldDispatch := false
	if err := s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		var current db.MergeGroup
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, group.ID).Error; err != nil {
			return err
		}
		group = current
		if current.Status == mergeGroupChecksRequested {
			shouldDispatch = true
			return createMergeGroupLifecycleEventTx(tx, current.ID, mergeGroupLifecycleChecksRequested, "", actor.ID)
		}
		if current.Status != mergeGroupPublishing {
			return nil
		}
		var queue db.MergeQueue
		if err := tx.Preload("Repository.Owner").First(&queue, current.QueueID).Error; err != nil {
			return err
		}
		if s.testMergeGroupBeforeRefPublish != nil {
			s.testMergeGroupBeforeRefPublish(current.ID)
		}
		if strings.TrimSpace(current.HeadRef) != "" {
			if err := gitStore.UpdateRef(ctx, queue.Repository.FullName, current.HeadRef, current.HeadSHA); err != nil {
				return err
			}
		}
		now := time.Now().UTC()
		if err := tx.Model(&current).Updates(map[string]any{
			"status": mergeGroupChecksRequested, "ref_published_at": &now, "updated_at": now,
		}).Error; err != nil {
			return err
		}
		group.Status = mergeGroupChecksRequested
		group.RefPublishedAt = &now
		shouldDispatch = true
		return createMergeGroupLifecycleEventTx(tx, current.ID, mergeGroupLifecycleChecksRequested, "", actor.ID)
	}); err != nil {
		return err
	}
	if !shouldDispatch {
		return nil
	}
	return s.dispatchMergeGroup(ctx, group, actor)
}

func createMergeGroupLifecycleEventTx(tx *gorm.DB, groupID uint, kind, reason string, actorID uint) error {
	var durableActorID *uint
	if actorID != 0 {
		durableActorID = &actorID
	}
	event := db.MergeGroupLifecycleEvent{
		MergeGroupID: groupID, Kind: kind, Reason: reason, ActorID: durableActorID,
	}
	return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&event).Error
}

func terminalizeMergeGroupsForEntriesTx(tx *gorm.DB, queueID uint, entryIDs []uint, fromStatuses []string, status, reason string, actorID uint) error {
	if len(entryIDs) == 0 {
		return nil
	}
	var groups []db.MergeGroup
	if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
		Where("queue_id = ? AND head_entry_id IN ? AND status IN ?", queueID, entryIDs, fromStatuses).
		Order("id ASC").Find(&groups).Error; err != nil {
		return err
	}
	now := time.Now().UTC()
	for i := range groups {
		groupActorID := groups[i].ActorID
		if groupActorID == 0 {
			groupActorID = actorID
		}
		result := tx.Model(&db.MergeGroup{}).Where("id = ? AND status IN ?", groups[i].ID, fromStatuses).
			Updates(map[string]any{"status": status, "terminal_reason": reason, "updated_at": now})
		if result.Error != nil {
			return result.Error
		}
		if result.RowsAffected == 0 {
			continue
		}
		if err := createMergeGroupLifecycleEventTx(tx, groups[i].ID, mergeGroupLifecycleDestroyed, reason, groupActorID); err != nil {
			return err
		}
	}
	return nil
}

func terminalizeMergeGroupTx(tx *gorm.DB, group db.MergeGroup, fromStatuses []string, status, reason string, actorID uint) error {
	var current db.MergeGroup
	if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND status IN ?", group.ID, fromStatuses).First(&current).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil
		}
		return err
	}
	if current.ActorID == 0 {
		current.ActorID = actorID
	}
	now := time.Now().UTC()
	result := tx.Model(&db.MergeGroup{}).Where("id = ? AND status IN ?", current.ID, fromStatuses).
		Updates(map[string]any{"status": status, "terminal_reason": reason, "updated_at": now})
	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected == 0 {
		return nil
	}
	return createMergeGroupLifecycleEventTx(tx, current.ID, mergeGroupLifecycleDestroyed, reason, current.ActorID)
}

func (s *Service) ensureMergeGroupLifecycleEvent(ctx context.Context, group db.MergeGroup, kind, reason string, actorID uint) error {
	return createMergeGroupLifecycleEventTx(s.DBForCtx(ctx), group.ID, kind, reason, actorID)
}

// processMergeGroupLifecycleEvents drains Actions work derived from the
// durable lifecycle rows. GitHub's merge_group webhook is App-only, and AGS
// does not yet have an App installation delivery model, so these events must
// never be routed through repository webhooks.
func (s *Service) processMergeGroupLifecycleEvents(ctx context.Context, groupID uint) error {
	var group db.MergeGroup
	if err := s.DBForCtx(ctx).First(&group, groupID).Error; err != nil {
		return err
	}
	var queue db.MergeQueue
	if err := s.DBForCtx(ctx).Preload("Repository.Owner").First(&queue, group.QueueID).Error; err != nil {
		return err
	}
	var events []db.MergeGroupLifecycleEvent
	if err := s.DBForCtx(ctx).Where("merge_group_id = ?", group.ID).Order("id ASC").Find(&events).Error; err != nil {
		return err
	}
	for i := range events {
		event := events[i]
		var actor db.User
		var actorID uint
		if event.ActorID != nil {
			actorID = *event.ActorID
		}
		if actorID == 0 {
			actorID = group.ActorID
		}
		if actorID != 0 {
			if err := s.DBForCtx(ctx).First(&actor, actorID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
				return err
			}
		}
		if actor.ID == 0 {
			var entry db.MergeQueueEntry
			if err := s.DBForCtx(ctx).Preload("EnqueuedBy").First(&entry, group.HeadEntryID).Error; err == nil {
				actor = entry.EnqueuedBy
			}
		}
		if event.Kind == mergeGroupLifecycleChecksRequested && event.ActionsDispatchedAt == nil {
			if err := s.dispatchMergeGroupWorkflows(ctx, queue.Repository, group, actor); err != nil {
				return err
			}
			now := time.Now().UTC()
			if err := s.DBForCtx(ctx).Model(&db.MergeGroupLifecycleEvent{}).Where("id = ? AND actions_dispatched_at IS NULL", event.ID).
				Update("actions_dispatched_at", &now).Error; err != nil {
				return err
			}
		}
	}
	if group.Status == mergeGroupChecksRequested {
		s.scheduleMergeGroupTimeout(ctx, group)
		s.ReevaluateMergeQueueForSHA(ctx, queue.RepositoryID, group.HeadSHA)
	}
	if group.TerminalReason != "" {
		return s.cleanupTerminalMergeGroupRef(ctx, queue, group)
	}
	return nil
}

func (s *Service) cleanupTerminalMergeGroupRef(ctx context.Context, queue db.MergeQueue, group db.MergeGroup) error {
	if group.RefDeletedAt != nil || strings.TrimSpace(group.HeadRef) == "" {
		return nil
	}
	gitStore, err := s.GitForCtx(ctx)
	if err != nil {
		return err
	}
	// Publication and terminalization share the group-row lock, so a new group
	// without a publication marker cannot race with a later publisher. Older
	// rows may predate that marker, though, and can still have a real ref that
	// recovery must remove. Inspect the ref before deciding whether cleanup is
	// necessary; an absent, unmarked ref must not be labeled as deleted.
	refExists := false
	if _, err := gitStore.LookupRef(ctx, queue.Repository.FullName, group.HeadRef); err == nil {
		refExists = true
	} else if errors.Is(err, gitstore.ErrRefNotFound) {
		if group.RefPublishedAt == nil {
			return nil
		}
	} else {
		return err
	}
	var event db.MergeGroupLifecycleEvent
	if err := s.DBForCtx(ctx).Where("merge_group_id = ? AND kind = ?", group.ID, mergeGroupLifecycleDestroyed).
		First(&event).Error; err != nil {
		return err
	}
	if refExists {
		if err := gitStore.DeleteRef(ctx, queue.Repository.FullName, group.HeadRef); err != nil {
			return err
		}
	}
	now := time.Now().UTC()
	return s.DBForCtx(ctx).Model(&db.MergeGroup{}).Where("id = ? AND ref_deleted_at IS NULL", group.ID).
		Update("ref_deleted_at", &now).Error
}
