package repocache

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/core/identifiers"
	"github.com/tidbcloud/AgentTeam/internal/service/kb"
	store "github.com/tidbcloud/AgentTeam/internal/store/repocache"
	"gorm.io/gorm"
)

const (
	defaultRefreshInterval = 24 * time.Hour
	defaultBuildTimeout    = 30 * time.Minute
	defaultLeaseDuration   = 45 * time.Minute
	buildLeaseBuffer       = 15 * time.Minute
	buildFailureTimeout    = 5 * time.Second
	maxNormalizedURLLength = 768
)

type Service struct {
	store        *store.Store
	root         string
	proxyURL     string
	now          func() time.Time
	buildTimeout time.Duration
	buildsMu     sync.Mutex
	// builds is keyed by the unique attempt token so overlapping stale and
	// current attempts for one cache retain independent cancellation handles.
	builds       map[string]*activeBuild
	attemptHooks attemptHooks
	cleanupMu    sync.Mutex
	cleanupAfter string
}

type activeBuild struct {
	attemptToken string
	cancel       context.CancelFunc
}

// attemptHooks is a nil-by-default test seam for deterministically exercising
// orchestration boundaries around the database lease and local handle registry.
type attemptHooks struct {
	afterClaim    func(context.Context, string, string)
	afterRegister func(context.Context, string, string)
}

type CreateInput struct {
	RemoteURL           string
	AutoRefreshEnabled  bool
	RefreshIntervalSecs int
	Actor               string
}

type UpdateInput struct {
	AutoRefreshEnabled  bool
	RefreshIntervalSecs int
	Actor               string
}

type cacheInfo struct {
	SizeBytes int64 `json:"sizeBytes"`
}

func New(repository *store.Store, root, proxyURL string, timeout time.Duration) *Service {
	if timeout <= 0 {
		timeout = defaultBuildTimeout
	}
	return &Service{store: repository, root: strings.TrimSpace(root), proxyURL: strings.TrimSpace(proxyURL), now: func() time.Time { return time.Now().UTC() }, buildTimeout: timeout, builds: map[string]*activeBuild{}}
}

// NormalizePublicRemoteURL applies the existing source mirror SSRF validation,
// then narrows the cache contract to credential-free public HTTPS remotes.
func NormalizePublicRemoteURL(ctx context.Context, raw string) (string, error) {
	normalized, err := kb.ValidateSourceMirrorRemoteForUpstream(ctx, "generic_git", raw, kb.SourceMirrorRemoteValidationOptions{})
	if err != nil {
		return "", err
	}
	parsed, err := url.Parse(normalized)
	if err != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.User != nil {
		return "", fmt.Errorf("public repository cache remote URL must use credential-free HTTPS")
	}
	parsed.Scheme = "https"
	parsed.Host = strings.ToLower(parsed.Host)
	parsed.Path = strings.TrimSuffix(parsed.Path, "/")
	parsed.RawPath = ""
	if !strings.HasSuffix(strings.ToLower(parsed.Path), ".git") {
		parsed.Path += ".git"
	}
	normalized = parsed.String()
	if len(normalized) > maxNormalizedURLLength {
		return "", fmt.Errorf("public repository cache remote URL must not exceed %d characters", maxNormalizedURLLength)
	}
	return normalized, nil
}

func (s *Service) Create(ctx context.Context, input CreateInput) (store.Cache, error) {
	if s == nil || s.store == nil {
		return store.Cache{}, fmt.Errorf("public repository cache service is not configured")
	}
	remoteURL, err := NormalizePublicRemoteURL(ctx, input.RemoteURL)
	if err != nil {
		return store.Cache{}, err
	}
	interval := input.RefreshIntervalSecs
	if interval <= 0 {
		interval = int(defaultRefreshInterval.Seconds())
	}
	id, err := identifiers.NewEntityID("prc")
	if err != nil {
		return store.Cache{}, fmt.Errorf("create public repository cache id: %w", err)
	}
	now := s.now()
	return s.store.Create(ctx, store.Cache{ID: id, NormalizedRemoteURL: remoteURL, DisplayRemoteURL: remoteURL, Status: store.StatusPending, AutoRefreshEnabled: input.AutoRefreshEnabled, RefreshIntervalSecs: interval, CreatedBy: strings.TrimSpace(input.Actor), UpdatedBy: strings.TrimSpace(input.Actor), CreatedAt: now, UpdatedAt: now})
}

func (s *Service) List(ctx context.Context, limit, offset int) ([]store.Cache, error) {
	items, err := s.store.List(ctx, limit, offset)
	if err != nil {
		return nil, err
	}
	return items, nil
}

func (s *Service) Update(ctx context.Context, id string, input UpdateInput) (store.Cache, error) {
	interval := input.RefreshIntervalSecs
	if interval <= 0 {
		interval = int(defaultRefreshInterval.Seconds())
	}
	return s.store.UpdateSettings(ctx, id, input.AutoRefreshEnabled, interval, input.Actor, s.now())
}

func (s *Service) Build(ctx context.Context, id, actor string) (store.Cache, error) {
	attemptToken, err := identifiers.NewEntityID("prca")
	if err != nil {
		return store.Cache{}, fmt.Errorf("create public repository cache build attempt: %w", err)
	}
	cache, claimed, err := s.store.ClaimBuild(ctx, id, attemptToken, actor, s.now(), buildLeaseDuration(s.buildTimeout))
	if err != nil {
		s.failBuildDetached(ctx, strings.TrimSpace(id), attemptToken, err, s.now())
		return store.Cache{}, err
	}
	if !claimed {
		return cache, fmt.Errorf("public repository cache is already building")
	}
	if hook := s.attemptHooks.afterClaim; hook != nil {
		hook(ctx, cache.ID, attemptToken)
	}
	buildCtx, cancel := context.WithCancel(ctx)
	build := &activeBuild{attemptToken: attemptToken, cancel: cancel}
	defer func() {
		cancel()
		s.unregisterActiveBuild(build)
	}()
	if err := s.registerActiveBuild(ctx, cache.ID, build); err != nil {
		s.failBuildDetached(ctx, cache.ID, attemptToken, err, s.now())
		return store.Cache{}, err
	}
	if hook := s.attemptHooks.afterRegister; hook != nil {
		hook(buildCtx, cache.ID, attemptToken)
	}

	result, err := s.buildGeneration(buildCtx, cache)
	if err != nil {
		s.failBuildDetached(ctx, cache.ID, attemptToken, err, s.now())
		return store.Cache{}, err
	}
	completedAt := s.now()
	if err := s.markServingGenerationRetired(cache, completedAt); err != nil {
		s.failBuildDetached(ctx, cache.ID, attemptToken, err, completedAt)
		return store.Cache{}, err
	}
	info, err := json.Marshal(cacheInfo{SizeBytes: result.SizeBytes})
	if err != nil {
		return store.Cache{}, fmt.Errorf("encode public repository cache info: %w", err)
	}
	updated, err := s.store.CompleteBuild(ctx, cache.ID, attemptToken, result.Generation, result.Commit, string(info), completedAt)
	if err != nil {
		s.failBuildDetached(ctx, cache.ID, attemptToken, err, completedAt)
		return store.Cache{}, err
	}
	return updated, nil
}

func (s *Service) CancelBuild(ctx context.Context, id, actor string) (store.Cache, error) {
	id = strings.TrimSpace(id)
	// The DB lease is authoritative across MeshX instances. This process may
	// still hold a stale local attempt after another instance reclaimed it.
	current, err := s.store.Get(ctx, id)
	if err != nil {
		return store.Cache{}, err
	}
	if current.Status != store.StatusBuilding {
		return store.Cache{}, fmt.Errorf("public repository cache is not building")
	}
	attemptToken := current.LeaseOwner
	cancelledAt := s.now()
	cancelled, _, err := s.store.CancelBuild(ctx, id, attemptToken, strings.TrimSpace(actor), cancelledAt)
	// The token came from the authoritative DB row. Cancel this exact local
	// attempt even when the transition reports an error: the UPDATE outcome may
	// be ambiguous, while a concurrently reclaimed attempt has a different token.
	s.cancelLocalBuildAttempt(attemptToken)
	if err != nil {
		return store.Cache{}, err
	}
	return cancelled, nil
}

func (s *Service) failBuildDetached(ctx context.Context, cacheID, attemptToken string, buildErr error, now time.Time) {
	failureCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), buildFailureTimeout)
	defer cancel()
	_ = s.store.FailBuild(failureCtx, cacheID, attemptToken, buildErr.Error(), now)
}

func (s *Service) registerActiveBuild(ctx context.Context, cacheID string, build *activeBuild) error {
	s.buildsMu.Lock()
	s.builds[build.attemptToken] = build
	s.buildsMu.Unlock()

	current, err := s.store.Get(ctx, cacheID)
	if err != nil {
		s.cancelLocalBuildAttempt(build.attemptToken)
		return err
	}
	now := s.now()
	if current.Status == store.StatusBuilding && current.LeaseOwner == build.attemptToken && current.LeaseUntil != nil && current.LeaseUntil.After(now) {
		return nil
	}
	s.cancelLocalBuildAttempt(build.attemptToken)
	return fmt.Errorf("public repository cache build lease was lost")
}

func (s *Service) unregisterActiveBuild(build *activeBuild) {
	s.buildsMu.Lock()
	if s.builds[build.attemptToken] == build {
		delete(s.builds, build.attemptToken)
	}
	s.buildsMu.Unlock()
}

func (s *Service) cancelLocalBuildAttempt(attemptToken string) {
	s.buildsMu.Lock()
	build := s.builds[attemptToken]
	if build != nil {
		delete(s.builds, attemptToken)
	}
	s.buildsMu.Unlock()
	if build != nil {
		build.cancel()
	}
}

func buildLeaseDuration(buildTimeout time.Duration) time.Duration {
	if buildTimeout <= 0 {
		buildTimeout = defaultBuildTimeout
	}
	leaseDuration := buildTimeout + buildLeaseBuffer
	if leaseDuration < defaultLeaseDuration {
		return defaultLeaseDuration
	}
	return leaseDuration
}

func (s *Service) Delete(ctx context.Context, id string) error {
	legacyPath, err := s.legacyCachePath(id)
	if err != nil {
		return err
	}
	cacheRoot, err := s.cacheRoot(id)
	if err != nil {
		return err
	}
	if err := s.store.Delete(ctx, id); err != nil {
		return err
	}
	return errors.Join(os.RemoveAll(legacyPath), os.RemoveAll(cacheRoot))
}

func (s *Service) ResolveReady(ctx context.Context, remoteURL string) (kb.PublicRepoCacheResolution, bool, error) {
	normalized, err := NormalizePublicRemoteURL(ctx, remoteURL)
	if err != nil {
		return kb.PublicRepoCacheResolution{}, false, nil
	}
	cache, err := s.store.FindServingByRemoteURL(ctx, normalized)
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return kb.PublicRepoCacheResolution{}, false, nil
	}
	if err != nil {
		return kb.PublicRepoCacheResolution{}, false, err
	}
	path, err := s.servingPath(cache)
	if err != nil {
		return kb.PublicRepoCacheResolution{}, false, err
	}
	if _, err := os.Stat(path); err != nil {
		if os.IsNotExist(err) {
			return kb.PublicRepoCacheResolution{}, false, nil
		}
		return kb.PublicRepoCacheResolution{}, false, err
	}
	return kb.PublicRepoCacheResolution{ID: cache.ID, Path: path}, true, nil
}

// RefreshDue performs a bounded scheduler pass. ClaimBuild provides the
// cross-process lease, so multiple MeshX instances may run this safely.
func (s *Service) RefreshDue(ctx context.Context, limit int) (int, error) {
	caches, err := s.store.ListDue(ctx, s.now(), limit)
	if err != nil {
		return 0, err
	}
	refreshed := 0
	var refreshErr error
	for _, cache := range caches {
		if _, err := s.Build(ctx, cache.ID, "meshx-public-repo-cache-scheduler"); err != nil {
			refreshErr = errors.Join(refreshErr, fmt.Errorf("refresh cache %s: %w", cache.ID, err))
			continue
		}
		refreshed++
	}
	return refreshed, refreshErr
}
