package authz

import (
	"context"
	"fmt"
	"strings"
	"sync"
	"time"

	"git.sr.ht/~sircmpwn/core-go/client"

	"go.bigb.es/sourcehut-compare/core"
)

// RepoInfo is the subset of git.sr.ht repository metadata compare.sr.ht needs
// to render a page. It carries no ownership or ACL data: the mere fact that the
// authorizer returned a RepoInfo means the viewer is allowed to see the repo.
type RepoInfo struct {
	ID          int
	Name        string
	Description string
	Visibility  string
}

// Authorizer decides, for a given viewer, whether a repository may be seen and
// returns its metadata. A nil *RepoInfo is never returned alongside a nil
// error; an unauthorized or missing repo is reported as core.ErrNotFound so
// callers cannot distinguish "forbidden" from "absent".
type Authorizer interface {
	// Repo resolves a single repository owned by owner (with or without a
	// leading "~") as seen by viewer ("" for anonymous). A null user or null
	// repository yields core.ErrNotFound; transport/GraphQL failures yield a
	// wrapped error that is NOT core.ErrNotFound.
	Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error)
	// MyRepos lists every repository owned by viewer. viewer must be non-empty.
	MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error)
}

// maxMyRepos caps the number of repositories MyRepos will accumulate across
// pages, bounding memory and request count for pathological accounts.
const maxMyRepos = 500

type cacheEntry struct {
	info     *RepoInfo // nil when notFound
	notFound bool
	expiry   time.Time
}

// GQLAuthorizer implements Authorizer against git.sr.ht's internal GraphQL API
// via core-go's client.Do, memoizing Repo results in a TTL cache. It is safe
// for concurrent use.
type GQLAuthorizer struct {
	ttl time.Duration

	mu        sync.Mutex
	cache     map[string]cacheEntry
	lastSweep time.Time
}

// NewAuthorizer returns a GQLAuthorizer whose positive and not-found Repo
// results are cached for ttl (the caller supplies 60s in production).
func NewAuthorizer(ttl time.Duration) *GQLAuthorizer {
	return &GQLAuthorizer{
		ttl:   ttl,
		cache: make(map[string]cacheEntry),
	}
}

// repoQuery asks for one repository under a user; both user and repository come
// back null when the viewer may not see them. username is passed WITHOUT "~".
const repoQuery = `query($u:String!,$r:String!){user(username:$u){repository(name:$r){id name description visibility}}}`

func (a *GQLAuthorizer) Repo(ctx context.Context, viewer, owner, name string) (*RepoInfo, error) {
	owner = strings.TrimPrefix(owner, "~")
	key := cacheKey(viewer, owner, name)

	if info, notFound, ok := a.load(key); ok {
		if notFound {
			return nil, core.ErrNotFound
		}
		return info, nil
	}

	var result struct {
		User *struct {
			Repository *struct {
				ID          int    `json:"id"`
				Name        string `json:"name"`
				Description string `json:"description"`
				Visibility  string `json:"visibility"`
			} `json:"repository"`
		} `json:"user"`
	}
	query := client.GraphQLQuery{
		Query:     repoQuery,
		Variables: map[string]any{"u": owner, "r": name},
	}
	if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil {
		// Transport or GraphQL error — do NOT cache and do NOT mask as
		// not-found; the web layer distinguishes 404 from 502.
		return nil, fmt.Errorf("git.sr.ht repository query for ~%s/%s: %w", owner, name, err)
	}
	if result.User == nil || result.User.Repository == nil {
		a.store(key, cacheEntry{notFound: true})
		return nil, core.ErrNotFound
	}

	repo := result.User.Repository
	info := &RepoInfo{
		ID:          repo.ID,
		Name:        repo.Name,
		Description: repo.Description,
		Visibility:  repo.Visibility,
	}
	a.store(key, cacheEntry{info: info})
	return info, nil
}

// myReposQuery paginates the viewer's own repositories via the cursor scalar.
const myReposQuery = `query($c:Cursor){me{repositories(cursor:$c){results{id name description visibility} cursor}}}`

func (a *GQLAuthorizer) MyRepos(ctx context.Context, viewer string) ([]RepoInfo, error) {
	if viewer == "" {
		return nil, fmt.Errorf("authz: MyRepos requires an authenticated viewer")
	}

	var repos []RepoInfo
	var cursor *string
	for {
		var result struct {
			Me struct {
				Repositories struct {
					Results []struct {
						ID          int    `json:"id"`
						Name        string `json:"name"`
						Description string `json:"description"`
						Visibility  string `json:"visibility"`
					} `json:"results"`
					Cursor *string `json:"cursor"`
				} `json:"repositories"`
			} `json:"me"`
		}
		query := client.GraphQLQuery{
			Query:     myReposQuery,
			Variables: map[string]any{"c": cursor},
		}
		if err := client.Do(ctx, viewer, "git.sr.ht", query, &result); err != nil {
			return nil, fmt.Errorf("git.sr.ht repositories query for ~%s: %w", viewer, err)
		}

		for _, r := range result.Me.Repositories.Results {
			repos = append(repos, RepoInfo{
				ID:          r.ID,
				Name:        r.Name,
				Description: r.Description,
				Visibility:  r.Visibility,
			})
			if len(repos) >= maxMyRepos {
				return repos, nil
			}
		}

		if result.Me.Repositories.Cursor == nil {
			break
		}
		cursor = result.Me.Repositories.Cursor
	}
	return repos, nil
}

func cacheKey(viewer, owner, name string) string {
	return viewer + "\x00" + owner + "\x00" + name
}

// load returns a cached entry if present and unexpired, pruning it lazily on a
// hit that has aged out.
func (a *GQLAuthorizer) load(key string) (info *RepoInfo, notFound, ok bool) {
	a.mu.Lock()
	defer a.mu.Unlock()
	e, exists := a.cache[key]
	if !exists {
		return nil, false, false
	}
	if time.Now().After(e.expiry) {
		delete(a.cache, key)
		return nil, false, false
	}
	return e.info, e.notFound, true
}

// store records an entry with a fresh expiry and opportunistically sweeps the
// whole map at most once per ttl, so no background goroutine is needed.
func (a *GQLAuthorizer) store(key string, e cacheEntry) {
	a.mu.Lock()
	defer a.mu.Unlock()
	now := time.Now()
	e.expiry = now.Add(a.ttl)
	if now.Sub(a.lastSweep) > a.ttl {
		for k, v := range a.cache {
			if now.After(v.expiry) {
				delete(a.cache, k)
			}
		}
		a.lastSweep = now
	}
	a.cache[key] = e
}

// compile-time assertion that GQLAuthorizer satisfies Authorizer.
var _ Authorizer = (*GQLAuthorizer)(nil)
