package spindle

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/url"
	"slices"
	"sync"
	"time"

	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/hashicorp/golang-lru/v2/expirable"
	gossh "golang.org/x/crypto/ssh"

	"tangled.org/core/api/org_tangled"
	"tangled.org/core/rbac"
	"tangled.org/core/spindle/knotclient"
)

const (
	debugKeyTTL = time.Minute
	// the owner plus this many collaborators at most, so one login can't fan out to every pds
	debugKeyHolderCap  = 20
	debugKeysPerHolder = 50
	debugKeyCacheSize  = 512
)

// marshalled keys per did, only successful reads get cached so a pds blip doesn't lock people out for a minute
type debugKeyCache struct {
	once  sync.Once
	byDid *expirable.LRU[syntax.DID, [][]byte]
}

func (c *debugKeyCache) lru() *expirable.LRU[syntax.DID, [][]byte] {
	c.once.Do(func() {
		c.byDid = expirable.NewLRU[syntax.DID, [][]byte](debugKeyCacheSize, nil, debugKeyTTL)
	})
	return c.byDid
}

// fails closed, any error along the way refuses the key
func (s *Spindle) DebugKeyAllowed(ctx context.Context, repoDid syntax.DID, key gossh.PublicKey) bool {
	l := s.l.With("component", "debugssh", "repo", repoDid)
	repo, err := s.db.GetRepoByDid(repoDid)
	if err != nil {
		l.Warn("debug key refused, repo lookup failed", "err", err)
		return false
	}
	if ban, err := s.db.IsBanned(repoDid, repo.Owner); err != nil || ban != nil {
		l.Warn("debug key refused, repo is banned or the ban lookup failed", "err", err)
		return false
	}
	if gate, err := s.db.Gate(&s.cfg.Server, repoDid); err != nil || gate.Paused {
		l.Warn("debug key refused, owner is paused or the gate lookup failed", "err", err)
		return false
	}

	want := key.Marshal()
	for _, holder := range s.debugKeyHolders(l, repoDid, repo.Owner) {
		keys, err := s.sshKeysOf(ctx, holder)
		if err != nil {
			l.Warn("debug key holder skipped, reading their keys failed", "holder", holder, "err", err)
			continue
		}
		if slices.ContainsFunc(keys, func(k []byte) bool { return bytes.Equal(k, want) }) {
			return true
		}
	}
	return false
}

// owner first and a stable order, otherwise the cap could cut different collaborators each login
func (s *Spindle) debugKeyHolders(l *slog.Logger, repoDid, owner syntax.DID) []syntax.DID {
	candidates := []string{owner.String()}
	byRepo, err := s.e.GetCollaboratorsByRepo(rbac.ThisServer)
	if err != nil {
		l.Warn("debug key holders limited to the owner, collaborator lookup failed", "err", err)
	} else {
		candidates = append(candidates, byRepo[repoDid.String()]...)
	}

	var holders []syntax.DID
	for _, candidate := range candidates {
		did, err := syntax.ParseDID(candidate)
		if err != nil || slices.Contains(holders, did) {
			continue
		}
		if ok, err := s.e.IsPushAllowed(candidate, rbac.ThisServer, repoDid.String()); err != nil || !ok {
			continue
		}
		if ban, err := s.db.GetBan(did); err != nil || ban != nil {
			continue
		}
		holders = append(holders, did)
		if len(holders) == debugKeyHolderCap {
			break
		}
	}
	return holders
}

func (s *Spindle) sshKeysOf(ctx context.Context, did syntax.DID) ([][]byte, error) {
	cache := s.debugKeys.lru()
	if keys, ok := cache.Get(did); ok {
		return keys, nil
	}
	pds, err := s.pdsOf(ctx, did)
	if err != nil {
		return nil, err
	}
	records, err := s.knots.ListRecords(ctx, pds, did, org_tangled.KeySshKeyNSID, debugKeysPerHolder)
	if err != nil {
		return nil, fmt.Errorf("listing ssh keys of %s: %w", did, err)
	}
	keys := make([][]byte, 0, len(records))
	for _, raw := range records {
		var record org_tangled.KeySshKey
		if err := json.Unmarshal(raw, &record); err != nil {
			continue
		}
		// usages default to auth and sign when omitted
		if len(record.Usages) > 0 && !slices.Contains(record.Usages, "auth") {
			continue
		}
		parsed, _, _, _, err := gossh.ParseAuthorizedKey([]byte(record.PublicKey))
		if err != nil {
			continue
		}
		keys = append(keys, parsed.Marshal())
	}
	cache.Add(did, keys)
	return keys, nil
}

func (s *Spindle) pdsOf(ctx context.Context, did syntax.DID) (knotclient.Knot, error) {
	ident, err := s.res.ResolveIdent(ctx, did.String())
	if err != nil {
		return knotclient.Knot{}, fmt.Errorf("resolving %s: %w", did, err)
	}
	pds, ok := knotAt(ident.PDSEndpoint())
	if !ok {
		return knotclient.Knot{}, fmt.Errorf("%s has no usable pds endpoint", did)
	}
	return pds, nil
}

func knotAt(endpoint string) (knotclient.Knot, bool) {
	u, err := url.Parse(endpoint)
	if err != nil || u.Host == "" {
		return knotclient.Knot{}, false
	}
	return knotclient.Knot{Host: u.Host, Scheme: u.Scheme}, true
}
