package repocache

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

	"gorm.io/gorm"
)

const maxTransitionAttempts = 8

// TransitionKind identifies a fenced public Git mirror registry mutation.
type TransitionKind string

const (
	TransitionClaimBuild     TransitionKind = "claim_build"
	TransitionCompleteBuild  TransitionKind = "complete_build"
	TransitionFailBuild      TransitionKind = "fail_build"
	TransitionCancelBuild    TransitionKind = "cancel_build"
	TransitionUpdateSettings TransitionKind = "update_settings"
)

// TransitionStage identifies when transition instrumentation is emitted.
type TransitionStage string

const (
	TransitionStageRead    TransitionStage = "read"
	TransitionStageMutated TransitionStage = "mutated"
)

// TransitionEvent describes one bounded build-lease transition observation.
type TransitionEvent struct {
	Kind          TransitionKind
	Stage         TransitionStage
	Cache         Cache
	AttemptToken  string
	AttemptNumber int
}

// Option configures Store instrumentation without changing persistence semantics.
type Option func(*Store)

// WithTransitionObserver attaches synchronous transition instrumentation.
// Observers run after a read or durable mutation and must remain bounded.
func WithTransitionObserver(observer func(TransitionEvent)) Option {
	return func(store *Store) { store.transitionObserver = observer }
}

// Store persists the public Git bare-mirror registry and its fenced build leases.
// It is not a generic cache store or a resource-repository binding owner.
type Store struct {
	db                 *gorm.DB
	transitionObserver func(TransitionEvent)
}

// New constructs a public Git bare-mirror registry store.
func New(db *gorm.DB, options ...Option) *Store {
	store := &Store{db: db}
	for _, option := range options {
		if option != nil {
			option(store)
		}
	}
	return store
}

func (s *Store) observeTransition(event TransitionEvent) {
	if s != nil && s.transitionObserver != nil {
		s.transitionObserver(event)
	}
}

func (s *Store) ensureDB() error {
	if s == nil || s.db == nil {
		return fmt.Errorf("public repository cache store is not configured")
	}
	return nil
}

func (s *Store) Create(ctx context.Context, cache Cache) (Cache, error) {
	if err := s.ensureDB(); err != nil {
		return Cache{}, err
	}
	if strings.TrimSpace(cache.Info) == "" {
		cache.Info = "{}"
	}
	if err := s.db.WithContext(ctx).Create(&cache).Error; err != nil {
		return Cache{}, fmt.Errorf("create public repository cache: %w", err)
	}
	return cache, nil
}

func (s *Store) Get(ctx context.Context, id string) (Cache, error) {
	if err := s.ensureDB(); err != nil {
		return Cache{}, err
	}
	var cache Cache
	err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(id)).First(&cache).Error
	return cache, err
}

// FindServingByRemoteURL returns a cache with a published generation. The
// last_success_at predicate keeps pre-generation ready caches available during
// rolling upgrades until their first generation-based refresh succeeds.
func (s *Store) FindServingByRemoteURL(ctx context.Context, normalizedRemoteURL string) (Cache, error) {
	if err := s.ensureDB(); err != nil {
		return Cache{}, err
	}
	var cache Cache
	err := s.db.WithContext(ctx).
		Where("normalized_remote_url = ? AND (active_generation <> '' OR last_success_at IS NOT NULL)", strings.TrimSpace(normalizedRemoteURL)).
		First(&cache).Error
	return cache, err
}

func (s *Store) List(ctx context.Context, limit, offset int) ([]Cache, error) {
	if err := s.ensureDB(); err != nil {
		return nil, err
	}
	if limit <= 0 || limit > 100 {
		limit = 50
	}
	if offset < 0 {
		offset = 0
	}
	var caches []Cache
	err := s.db.WithContext(ctx).Order("updated_at DESC, id ASC").Limit(limit).Offset(offset).Find(&caches).Error
	return caches, err
}

// ListByIDAfter returns one bounded cleanup page. Callers can retain the last
// ID as a cursor and wrap to the empty cursor after the final page.
func (s *Store) ListByIDAfter(ctx context.Context, afterID string, limit int) ([]Cache, error) {
	if err := s.ensureDB(); err != nil {
		return nil, err
	}
	if limit <= 0 || limit > 100 {
		limit = 50
	}
	query := s.db.WithContext(ctx).Order("id ASC").Limit(limit)
	if afterID = strings.TrimSpace(afterID); afterID != "" {
		query = query.Where("id > ?", afterID)
	}
	var caches []Cache
	err := query.Find(&caches).Error
	return caches, err
}

func (s *Store) ListDue(ctx context.Context, now time.Time, limit int) ([]Cache, error) {
	if err := s.ensureDB(); err != nil {
		return nil, err
	}
	if limit <= 0 || limit > 20 {
		limit = 5
	}
	var caches []Cache
	err := s.db.WithContext(ctx).
		Where("auto_refresh_enabled = ? AND next_sync_at IS NOT NULL AND next_sync_at <= ? AND (status <> ? OR lease_until IS NULL OR lease_until <= ?)", true, now, StatusBuilding, now).
		Order("next_sync_at ASC, id ASC").Limit(limit).Find(&caches).Error
	return caches, err
}

func (s *Store) UpdateSettings(ctx context.Context, id string, enabled bool, intervalSeconds int, actor string, now time.Time) (Cache, error) {
	if err := s.ensureDB(); err != nil {
		return Cache{}, err
	}
	id = strings.TrimSpace(id)
	actor = strings.TrimSpace(actor)
	var next *time.Time
	if enabled {
		value := now.Add(time.Duration(intervalSeconds) * time.Second)
		next = &value
	}
	for attempt := 1; attempt <= maxTransitionAttempts; attempt++ {
		cache, err := s.Get(ctx, id)
		if err != nil {
			return Cache{}, err
		}
		s.observeTransition(TransitionEvent{Kind: TransitionUpdateSettings, Stage: TransitionStageRead, Cache: cache, AttemptNumber: attempt})
		result := whereTransitionSnapshot(s.db.WithContext(ctx).Model(&Cache{}).Where("id = ?", id), cache).
			Updates(map[string]any{"auto_refresh_enabled": enabled, "refresh_interval_seconds": intervalSeconds, "next_sync_at": next, "revision": gorm.Expr("revision + 1"), "updated_by": actor, "updated_at": now})
		if result.Error != nil {
			return Cache{}, result.Error
		}
		if result.RowsAffected == 0 {
			continue
		}
		cache.AutoRefreshEnabled = enabled
		cache.RefreshIntervalSecs = intervalSeconds
		cache.NextSyncAt = next
		cache.Revision++
		cache.UpdatedBy = actor
		cache.UpdatedAt = now
		s.observeTransition(TransitionEvent{Kind: TransitionUpdateSettings, Stage: TransitionStageMutated, Cache: cache, AttemptNumber: attempt})
		return cache, nil
	}
	return Cache{}, fmt.Errorf("public repository cache settings changed concurrently")
}

func (s *Store) ClaimBuild(ctx context.Context, id, attemptToken, actor string, now time.Time, leaseDuration time.Duration) (Cache, bool, error) {
	if err := s.ensureDB(); err != nil {
		return Cache{}, false, err
	}
	attemptToken, err := normalizeBuildAttemptToken(attemptToken)
	if err != nil {
		return Cache{}, false, err
	}
	id = strings.TrimSpace(id)
	actor = strings.TrimSpace(actor)
	leaseUntil := now.Add(leaseDuration)
	for attempt := 1; attempt <= maxTransitionAttempts; attempt++ {
		cache, err := s.Get(ctx, id)
		if err != nil {
			return Cache{}, false, err
		}
		s.observeTransition(TransitionEvent{Kind: TransitionClaimBuild, Stage: TransitionStageRead, Cache: cache, AttemptToken: attemptToken, AttemptNumber: attempt})
		if cache.Status == StatusBuilding && cache.LeaseUntil != nil && cache.LeaseUntil.After(now) {
			return cache, false, nil
		}
		result := whereTransitionSnapshot(s.db.WithContext(ctx).Model(&Cache{}).Where("id = ?", id), cache).
			Where("status <> ? OR lease_until IS NULL OR lease_until <= ?", StatusBuilding, now).
			Updates(map[string]any{"status": StatusBuilding, "lease_owner": attemptToken, "lease_until": leaseUntil, "last_error": "", "revision": gorm.Expr("revision + 1"), "updated_by": actor, "updated_at": now})
		if result.Error != nil {
			return Cache{}, false, result.Error
		}
		if result.RowsAffected == 0 {
			continue
		}
		cache.Status = StatusBuilding
		cache.LeaseOwner = attemptToken
		cache.LeaseUntil = &leaseUntil
		cache.LastError = ""
		cache.Revision++
		cache.UpdatedBy = actor
		cache.UpdatedAt = now
		s.observeTransition(TransitionEvent{Kind: TransitionClaimBuild, Stage: TransitionStageMutated, Cache: cache, AttemptToken: attemptToken, AttemptNumber: attempt})
		return cache, true, nil
	}
	return Cache{}, false, fmt.Errorf("public repository cache changed concurrently while claiming build")
}

func (s *Store) CompleteBuild(ctx context.Context, id, attemptToken, generation, commit, info string, now time.Time) (Cache, error) {
	attemptToken, err := normalizeBuildAttemptToken(attemptToken)
	if err != nil {
		return Cache{}, err
	}
	generation = strings.TrimSpace(generation)
	if generation == "" {
		return Cache{}, fmt.Errorf("public repository cache generation is required")
	}
	id = strings.TrimSpace(id)
	commit = strings.TrimSpace(commit)
	info = strings.TrimSpace(info)
	for attempt := 1; attempt <= maxTransitionAttempts; attempt++ {
		cache, err := s.Get(ctx, id)
		if err != nil {
			return Cache{}, err
		}
		s.observeTransition(TransitionEvent{Kind: TransitionCompleteBuild, Stage: TransitionStageRead, Cache: cache, AttemptToken: attemptToken, AttemptNumber: attempt})
		if cache.Status != StatusBuilding || cache.LeaseOwner != attemptToken || cache.LeaseUntil == nil || !cache.LeaseUntil.After(now) {
			return Cache{}, fmt.Errorf("public repository cache build lease was lost")
		}
		var next *time.Time
		if cache.AutoRefreshEnabled {
			value := now.Add(time.Duration(cache.RefreshIntervalSecs) * time.Second)
			next = &value
		}
		result := whereTransitionSnapshot(s.db.WithContext(ctx).Model(&Cache{}).Where("id = ?", id), cache).
			Where("status = ? AND lease_owner = ? AND lease_until IS NOT NULL AND lease_until > ?", StatusBuilding, attemptToken, now).
			Updates(map[string]any{"status": StatusReady, "active_generation": generation, "last_sync_at": now, "last_success_at": now, "next_sync_at": next, "last_commit": commit, "last_error": "", "info": info, "lease_owner": "", "lease_until": nil, "revision": gorm.Expr("revision + 1"), "updated_at": now})
		if result.Error != nil {
			return Cache{}, result.Error
		}
		if result.RowsAffected == 0 {
			continue
		}
		cache.Status = StatusReady
		cache.ActiveGeneration = generation
		cache.LastSyncAt = &now
		cache.LastSuccessAt = &now
		cache.NextSyncAt = next
		cache.LastCommit = commit
		cache.LastError = ""
		cache.Info = info
		cache.LeaseOwner = ""
		cache.LeaseUntil = nil
		cache.Revision++
		cache.UpdatedAt = now
		s.observeTransition(TransitionEvent{Kind: TransitionCompleteBuild, Stage: TransitionStageMutated, Cache: cache, AttemptToken: attemptToken, AttemptNumber: attempt})
		return cache, nil
	}
	return Cache{}, fmt.Errorf("public repository cache settings changed concurrently while completing build")
}

func (s *Store) FailBuild(ctx context.Context, id, attemptToken, message string, now time.Time) error {
	attemptToken, err := normalizeBuildAttemptToken(attemptToken)
	if err != nil {
		return err
	}
	id = strings.TrimSpace(id)
	message = strings.TrimSpace(message)
	for attempt := 1; attempt <= maxTransitionAttempts; attempt++ {
		cache, err := s.Get(ctx, id)
		if err != nil {
			return err
		}
		s.observeTransition(TransitionEvent{Kind: TransitionFailBuild, Stage: TransitionStageRead, Cache: cache, AttemptToken: attemptToken, AttemptNumber: attempt})
		if cache.Status != StatusBuilding || cache.LeaseOwner != attemptToken || cache.LeaseUntil == nil || !cache.LeaseUntil.After(now) {
			return fmt.Errorf("public repository cache build lease was lost")
		}
		result := whereTransitionSnapshot(s.db.WithContext(ctx).Model(&Cache{}).Where("id = ?", id), cache).
			Where("status = ? AND lease_owner = ? AND lease_until IS NOT NULL AND lease_until > ?", StatusBuilding, attemptToken, now).
			Updates(map[string]any{"status": StatusFailed, "last_sync_at": now, "last_error": message, "lease_owner": "", "lease_until": nil, "revision": gorm.Expr("revision + 1"), "updated_at": now})
		if result.Error != nil {
			return result.Error
		}
		if result.RowsAffected == 0 {
			continue
		}
		cache.Status = StatusFailed
		cache.LastSyncAt = &now
		cache.LastError = message
		cache.LeaseOwner = ""
		cache.LeaseUntil = nil
		cache.Revision++
		cache.UpdatedAt = now
		s.observeTransition(TransitionEvent{Kind: TransitionFailBuild, Stage: TransitionStageMutated, Cache: cache, AttemptToken: attemptToken, AttemptNumber: attempt})
		return nil
	}
	return fmt.Errorf("public repository cache changed concurrently while failing build")
}

// CancelBuild returns whether its fenced mutation was applied. It deliberately
// performs no post-CAS read so callers can always stop the matching local work.
func (s *Store) CancelBuild(ctx context.Context, id, attemptToken, actor string, now time.Time) (Cache, bool, error) {
	attemptToken, err := normalizeBuildAttemptToken(attemptToken)
	if err != nil {
		return Cache{}, false, err
	}
	id = strings.TrimSpace(id)
	actor = strings.TrimSpace(actor)
	for attempt := 1; attempt <= maxTransitionAttempts; attempt++ {
		cache, err := s.Get(ctx, id)
		if err != nil {
			return Cache{}, false, err
		}
		s.observeTransition(TransitionEvent{Kind: TransitionCancelBuild, Stage: TransitionStageRead, Cache: cache, AttemptToken: attemptToken, AttemptNumber: attempt})
		if cache.Status != StatusBuilding || cache.LeaseOwner != attemptToken {
			return Cache{}, false, errors.New("public repository cache is no longer building")
		}
		result := whereTransitionSnapshot(s.db.WithContext(ctx).Model(&Cache{}).Where("id = ?", id), cache).
			Where("status = ? AND lease_owner = ?", StatusBuilding, attemptToken).
			Updates(map[string]any{"status": StatusCancelled, "last_error": "build cancelled", "lease_owner": "", "lease_until": nil, "revision": gorm.Expr("revision + 1"), "updated_by": actor, "updated_at": now})
		if result.Error != nil {
			return Cache{}, false, result.Error
		}
		if result.RowsAffected == 0 {
			continue
		}
		cache.Status = StatusCancelled
		cache.LastError = "build cancelled"
		cache.LeaseOwner = ""
		cache.LeaseUntil = nil
		cache.Revision++
		cache.UpdatedBy = actor
		cache.UpdatedAt = now
		s.observeTransition(TransitionEvent{Kind: TransitionCancelBuild, Stage: TransitionStageMutated, Cache: cache, AttemptToken: attemptToken, AttemptNumber: attempt})
		return cache, true, nil
	}
	return Cache{}, false, fmt.Errorf("public repository cache changed concurrently while cancelling build")
}

// whereTransitionSnapshot extends the revision fence with the scalar registry
// fields needed to detect any older MeshX transition that does not increment
// revision. Info is intentionally omitted because PostgreSQL json has no
// equality operator; legacy writers only change info as part of CompleteBuild,
// which also changes status, generation, lease, and sync fields fenced below.
func whereTransitionSnapshot(query *gorm.DB, cache Cache) *gorm.DB {
	query = query.
		Where("revision = ?", cache.Revision).
		Where("status = ?", cache.Status).
		Where("active_generation = ?", cache.ActiveGeneration).
		Where("auto_refresh_enabled = ?", cache.AutoRefreshEnabled).
		Where("refresh_interval_seconds = ?", cache.RefreshIntervalSecs).
		Where("last_commit = ?", cache.LastCommit).
		Where("last_error = ?", cache.LastError).
		Where("lease_owner = ?", cache.LeaseOwner).
		Where("updated_by = ?", cache.UpdatedBy).
		Where("updated_at = ?", cache.UpdatedAt)
	query = whereNullableTimeSnapshot(query, "last_sync_at", cache.LastSyncAt)
	query = whereNullableTimeSnapshot(query, "last_success_at", cache.LastSuccessAt)
	query = whereNullableTimeSnapshot(query, "next_sync_at", cache.NextSyncAt)
	return whereNullableTimeSnapshot(query, "lease_until", cache.LeaseUntil)
}

func whereNullableTimeSnapshot(query *gorm.DB, column string, value *time.Time) *gorm.DB {
	if value == nil {
		return query.Where(column + " IS NULL")
	}
	return query.Where(column+" = ?", *value)
}

func normalizeBuildAttemptToken(value string) (string, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return "", fmt.Errorf("public repository cache build attempt token is required")
	}
	return value, nil
}

func (s *Store) Delete(ctx context.Context, id string) error {
	if err := s.ensureDB(); err != nil {
		return err
	}
	result := s.db.WithContext(ctx).Where("id = ? AND status <> ?", strings.TrimSpace(id), StatusBuilding).Delete(&Cache{})
	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected == 0 {
		var cache Cache
		if err := s.db.WithContext(ctx).Where("id = ?", id).First(&cache).Error; err != nil {
			return err
		}
		return errors.New("public repository cache is building")
	}
	return nil
}
