package lockdown

import (
	"context"
	"fmt"
	"log/slog"
	"strings"
	"sync"
	"time"

	"github.com/google/go-github/v87/github"
	"github.com/muesli/cache2go"
	"github.com/shurcooL/githubv4"
)

// RepoAccessCache caches repository metadata related to lockdown checks so that
// multiple tools can reuse the same access information safely across goroutines.
type RepoAccessCache struct {
	client           *githubv4.Client
	restClient       *github.Client
	mu               sync.Mutex
	cache            *cache2go.CacheTable
	ttl              time.Duration
	logger           *slog.Logger
	trustedBotLogins map[string]struct{}
}

type repoAccessCacheEntry struct {
	isPrivate   bool
	knownUsers  map[string]bool // normalized login -> has push access
	viewerLogin string
}

// RepoAccessInfo captures repository metadata needed for lockdown decisions.
type RepoAccessInfo struct {
	IsPrivate     bool
	HasPushAccess bool
	ViewerLogin   string
}

const (
	defaultRepoAccessTTL      = 20 * time.Minute
	defaultRepoAccessCacheKey = "repo-access-cache"
)

var (
	instance   *RepoAccessCache
	instanceMu sync.Mutex
)

// RepoAccessOption configures RepoAccessCache at construction time.
type RepoAccessOption func(*RepoAccessCache)

// WithTTL overrides the default TTL applied to cache entries. A non-positive
// duration disables expiration.
func WithTTL(ttl time.Duration) RepoAccessOption {
	return func(c *RepoAccessCache) {
		c.ttl = ttl
	}
}

// WithLogger sets the logger used for cache diagnostics.
func WithLogger(logger *slog.Logger) RepoAccessOption {
	return func(c *RepoAccessCache) {
		c.logger = logger
	}
}

// WithCacheName overrides the cache table name used for storing entries. This option is intended for tests
// that need isolated cache instances.
func WithCacheName(name string) RepoAccessOption {
	return func(c *RepoAccessCache) {
		if name != "" {
			c.cache = cache2go.Cache(name)
		}
	}
}

// GetInstance returns the singleton instance of RepoAccessCache.
// It initializes the instance on first call with the provided client and options.
// Subsequent calls ignore the client and options parameters and return the existing instance.
// This is the preferred way to access the cache in production code.
func GetInstance(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache {
	instanceMu.Lock()
	defer instanceMu.Unlock()
	if instance == nil {
		instance = newRepoAccessCache(client, restClient, opts...)
	}
	return instance
}

// NewRepoAccessCache creates a standalone cache instance, used for tests.
func NewRepoAccessCache(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache {
	return newRepoAccessCache(client, restClient, opts...)
}

func newRepoAccessCache(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache {
	c := &RepoAccessCache{
		client:     client,
		restClient: restClient,
		cache:      cache2go.Cache(defaultRepoAccessCacheKey),
		ttl:        defaultRepoAccessTTL,
		trustedBotLogins: map[string]struct{}{
			"copilot":             {},
			"github-actions[bot]": {},
		},
	}
	for _, opt := range opts {
		if opt != nil {
			opt(c)
		}
	}
	return c
}

// SetLogger updates the logger used for cache diagnostics.
func (c *RepoAccessCache) SetLogger(logger *slog.Logger) {
	c.mu.Lock()
	c.logger = logger
	c.mu.Unlock()
}

// CacheStats summarizes cache activity counters.
type CacheStats struct {
	Hits      int64
	Misses    int64
	Evictions int64
}

// IsSafeContent determines if the specified user can safely access the requested repository content.
// Safe access applies when any of the following is true:
// - the content was created by a trusted bot;
// - the author currently has push access to the repository;
// - the repository is private;
// - the content was created by the viewer.
func (c *RepoAccessCache) IsSafeContent(ctx context.Context, username, owner, repo string) (bool, error) {
	if c == nil {
		return false, fmt.Errorf("nil repo access cache")
	}

	if c.isTrustedBot(username) {
		return true, nil
	}

	repoInfo, err := c.getRepoAccessInfo(ctx, username, owner, repo)
	if err != nil {
		return false, err
	}

	c.logDebug(ctx, fmt.Sprintf("evaluated repo access for user %s to %s/%s for content filtering, result: hasPushAccess=%t, isPrivate=%t",
		username, owner, repo, repoInfo.HasPushAccess, repoInfo.IsPrivate))

	if repoInfo.IsPrivate || repoInfo.ViewerLogin == strings.ToLower(username) {
		return true, nil
	}
	return repoInfo.HasPushAccess, nil
}

func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner, repo string) (RepoAccessInfo, error) {
	if c == nil {
		return RepoAccessInfo{}, fmt.Errorf("nil repo access cache")
	}

	key := cacheKey(owner, repo)
	userKey := strings.ToLower(username)
	c.mu.Lock()
	defer c.mu.Unlock()

	// Try to get entry from cache - this will keep the item alive if it exists
	cacheItem, err := c.cache.Value(key)
	if err == nil {
		entry := cacheItem.Data().(*repoAccessCacheEntry)
		if cachedHasPush, known := entry.knownUsers[userKey]; known {
			c.logDebug(ctx, fmt.Sprintf("repo access cache hit for user %s to %s/%s", username, owner, repo))
			return RepoAccessInfo{
				IsPrivate:     entry.isPrivate,
				HasPushAccess: cachedHasPush,
				ViewerLogin:   entry.viewerLogin,
			}, nil
		}

		c.logDebug(ctx, "known users cache miss, fetching permission")

		hasPush, pushErr := c.checkPushAccess(ctx, username, owner, repo)
		if pushErr != nil {
			return RepoAccessInfo{}, pushErr
		}

		entry.knownUsers[userKey] = hasPush
		c.cache.Add(key, c.ttl, entry)

		return RepoAccessInfo{
			IsPrivate:     entry.isPrivate,
			HasPushAccess: hasPush,
			ViewerLogin:   entry.viewerLogin,
		}, nil
	}

	c.logDebug(ctx, fmt.Sprintf("repo access cache miss for user %s to %s/%s", username, owner, repo))

	info, queryErr := c.queryRepoAccessInfo(ctx, username, owner, repo)
	if queryErr != nil {
		return RepoAccessInfo{}, queryErr
	}

	// Create new entry
	entry := &repoAccessCacheEntry{
		knownUsers:  map[string]bool{userKey: info.HasPushAccess},
		isPrivate:   info.IsPrivate,
		viewerLogin: info.ViewerLogin,
	}
	c.cache.Add(key, c.ttl, entry)

	return RepoAccessInfo{
		IsPrivate:     entry.isPrivate,
		HasPushAccess: entry.knownUsers[userKey],
		ViewerLogin:   entry.viewerLogin,
	}, nil
}

func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, username, owner, repo string) (RepoAccessInfo, error) {
	if c.client == nil {
		return RepoAccessInfo{}, fmt.Errorf("nil GraphQL client")
	}

	var query struct {
		Viewer struct {
			Login githubv4.String
		}
		Repository struct {
			IsPrivate githubv4.Boolean
		} `graphql:"repository(owner: $owner, name: $name)"`
	}

	variables := map[string]any{
		"owner": githubv4.String(owner),
		"name":  githubv4.String(repo),
	}

	if err := c.client.Query(ctx, &query, variables); err != nil {
		return RepoAccessInfo{}, fmt.Errorf("failed to query repository metadata: %w", err)
	}

	hasPush, err := c.checkPushAccess(ctx, username, owner, repo)
	if err != nil {
		return RepoAccessInfo{}, err
	}

	c.logDebug(ctx, fmt.Sprintf("queried repo access info for user %s to %s/%s: isPrivate=%t, hasPushAccess=%t, viewerLogin=%s",
		username, owner, repo, bool(query.Repository.IsPrivate), hasPush, query.Viewer.Login))

	return RepoAccessInfo{
		IsPrivate:     bool(query.Repository.IsPrivate),
		HasPushAccess: hasPush,
		ViewerLogin:   string(query.Viewer.Login),
	}, nil
}

// checkPushAccess checks if the user has push access to the repository via the REST permission endpoint.
func (c *RepoAccessCache) checkPushAccess(ctx context.Context, username, owner, repo string) (bool, error) {
	if c.restClient == nil {
		return false, fmt.Errorf("nil REST client")
	}

	permLevel, _, err := c.restClient.Repositories.GetPermissionLevel(ctx, owner, repo, username)
	if err != nil {
		return false, fmt.Errorf("failed to get user permission level: %w", err)
	}

	// REST API maps "maintain" to "write" (and "triage" to "read")
	// https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user
	permission := permLevel.GetPermission()
	return permission == "admin" || permission == "write", nil
}

func (c *RepoAccessCache) log(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) {
	if c == nil || c.logger == nil {
		return
	}
	if !c.logger.Enabled(ctx, level) {
		return
	}
	c.logger.LogAttrs(ctx, level, msg, attrs...)
}

func (c *RepoAccessCache) logDebug(ctx context.Context, msg string, attrs ...slog.Attr) {
	c.log(ctx, slog.LevelDebug, msg, attrs...)
}

func (c *RepoAccessCache) isTrustedBot(username string) bool {
	_, ok := c.trustedBotLogins[strings.ToLower(username)]
	return ok
}

func cacheKey(owner, repo string) string {
	return fmt.Sprintf("%s/%s", strings.ToLower(owner), strings.ToLower(repo))
}
