package spindle

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"sync"
	"testing"

	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"

	"tangled.org/core/api/org_tangled"
	"tangled.org/core/idresolver"
	"tangled.org/core/rbac"
	"tangled.org/core/spindle/config"
	"tangled.org/core/spindle/db"
	"tangled.org/core/spindle/knotclient"
	"tangled.org/core/spindle/webhook"
)

const (
	testOwner   = syntax.DID("did:plc:owner")
	testRepoDid = syntax.DID("did:plc:repo")
	testBob     = syntax.DID("did:plc:bob")
	testCarol   = syntax.DID("did:plc:carol")
	testDave    = syntax.DID("did:plc:dave")
)

type fakeKnot struct {
	srv *httptest.Server

	mu       sync.Mutex
	records  map[string]any
	requests int
	failing  bool

	// a proxy in front answers a bare 404, which isn't the repo being gone
	bare404 bool
	// answers 500 for this collection only, the knot also plays the owner's pds
	failCollection string
	// runs before the lock, otherwise a stalled hook would block the other requests
	beforeAnswer func(r *http.Request)
	failVersions bool
	failStatus   bool

	// who wrote each version of a record, oldest first, keyed like records
	editors map[string][]syntax.DID
	bodies  map[string][]map[string]any

	deleted map[syntax.DID]bool
}

func newFakeKnot(t *testing.T) *fakeKnot {
	t.Helper()
	return newFakeKnotBehind(t, nil)
}

func newFakeKnotBehind(t *testing.T, wrap func(http.Handler) http.Handler) *fakeKnot {
	t.Helper()
	k := &fakeKnot{records: map[string]any{}}
	var handler http.Handler = http.HandlerFunc(k.serve)
	if wrap != nil {
		handler = wrap(handler)
	}
	k.srv = httptest.NewServer(handler)
	t.Cleanup(k.srv.Close)
	return k
}

func (k *fakeKnot) host() string {
	u, _ := url.Parse(k.srv.URL)
	return u.Host
}

func (k *fakeKnot) put(repo syntax.DID, collection, rkey string, value any) {
	k.mu.Lock()
	defer k.mu.Unlock()
	k.records[repo.String()+"|"+collection+"|"+rkey] = value
}

// like the knot, stamps the editor into the record and remembers every version's
func (k *fakeKnot) putVersion(repo syntax.DID, collection, rkey string, editor syntax.DID, value map[string]any) {
	value["x-tngl-editor"] = editor.String()
	key := repo.String() + "|" + collection + "|" + rkey
	k.mu.Lock()
	defer k.mu.Unlock()
	if k.editors == nil {
		k.editors = map[string][]syntax.DID{}
		k.bodies = map[string][]map[string]any{}
	}
	k.records[key] = value
	k.editors[key] = append(k.editors[key], editor)
	body := make(map[string]any, len(value))
	for name, v := range value {
		body[name] = v
	}
	k.bodies[key] = append(k.bodies[key], body)
}

func (k *fakeKnot) erase(repo syntax.DID, collection, rkey string) {
	k.mu.Lock()
	defer k.mu.Unlock()
	delete(k.records, repo.String()+"|"+collection+"|"+rkey)
}

// like knot2, keeps saying a deleted repo was deleted
func (k *fakeKnot) deleteRepo(repo syntax.DID) {
	k.mu.Lock()
	defer k.mu.Unlock()
	for key := range k.records {
		if strings.HasPrefix(key, repo.String()+"|") {
			delete(k.records, key)
		}
	}
	if k.deleted == nil {
		k.deleted = map[syntax.DID]bool{}
	}
	k.deleted[repo] = true
}

func (k *fakeKnot) declare(owner syntax.DID, rkey string, repo syntax.DID) {
	k.put(owner, org_tangled.RepoDeclarationNSID, rkey, org_tangled.RepoDeclaration{LexiconTypeID: org_tangled.RepoDeclarationNSID, Repo: repo.String()})
}

func (k *fakeKnot) undeclare(owner syntax.DID, rkey string) {
	k.erase(owner, org_tangled.RepoDeclarationNSID, rkey)
}

func (k *fakeKnot) count() int {
	k.mu.Lock()
	defer k.mu.Unlock()
	return k.requests
}

func (k *fakeKnot) serve(w http.ResponseWriter, r *http.Request) {
	if k.beforeAnswer != nil {
		k.beforeAnswer(r)
	}
	k.mu.Lock()
	defer k.mu.Unlock()
	k.requests++
	if k.failing {
		http.Error(w, "boom", http.StatusInternalServerError)
		return
	}
	if k.bare404 {
		http.NotFound(w, r)
		return
	}
	q := r.URL.Query()
	if k.failCollection != "" && q.Get("collection") == k.failCollection {
		http.Error(w, "boom", http.StatusInternalServerError)
		return
	}
	prefix := q.Get("repo") + "|" + q.Get("collection") + "|"
	switch strings.TrimPrefix(r.URL.Path, "/xrpc/") {
	case "com.atproto.repo.getRecord":
		value, ok := k.records[prefix+q.Get("rkey")]
		if at, found := strings.CutPrefix(q.Get("cid"), "bafyv"); found && ok {
			i, err := strconv.Atoi(at)
			bodies := k.bodies[prefix+q.Get("rkey")]
			if err != nil || i < 0 || i >= len(bodies) {
				ok = false
			} else {
				value = bodies[i]
			}
		}
		if !ok {
			w.WriteHeader(http.StatusBadRequest)
			json.NewEncoder(w).Encode(map[string]string{"error": "RecordNotFound"})
			return
		}
		json.NewEncoder(w).Encode(map[string]any{"cid": "bafy", "value": value})
	case "com.atproto.repo.listRecords":
		var rkeys []string
		for key := range k.records {
			if rkey, ok := strings.CutPrefix(key, prefix); ok {
				rkeys = append(rkeys, rkey)
			}
		}
		sort.Strings(rkeys)
		// the cursor is just an offset, and a client that sends no limit gets everything
		start, _ := strconv.Atoi(q.Get("cursor"))
		end := len(rkeys)
		if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
			end = min(start+limit, end)
		}
		var records []map[string]any
		for _, rkey := range rkeys[min(start, end):end] {
			records = append(records, map[string]any{"uri": "at://" + q.Get("repo") + "/" + q.Get("collection") + "/" + rkey, "value": k.records[prefix+rkey]})
		}
		res := map[string]any{"records": records}
		if end < len(rkeys) {
			res["cursor"] = strconv.Itoa(end)
		}
		json.NewEncoder(w).Encode(res)
	case "com.atproto.sync.getRepoStatus":
		if k.failStatus {
			http.Error(w, "boom", http.StatusInternalServerError)
			return
		}
		did := q.Get("did")
		if k.deleted[syntax.DID(did)] {
			json.NewEncoder(w).Encode(map[string]any{"did": did, "active": false, "status": "deleted"})
			return
		}
		for key := range k.records {
			if strings.HasPrefix(key, did+"|") {
				json.NewEncoder(w).Encode(map[string]any{"did": did, "active": true})
				return
			}
		}
		w.WriteHeader(http.StatusBadRequest)
		json.NewEncoder(w).Encode(map[string]string{"error": "RepoNotFound"})
	case "org.tangled.repo.listRecordVersions":
		if k.failVersions {
			http.Error(w, "boom", http.StatusInternalServerError)
			return
		}
		uri := q.Get("uri")
		editors, ok := k.editors[strings.ReplaceAll(strings.TrimPrefix(uri, "at://"), "/", "|")]
		if !ok {
			w.WriteHeader(http.StatusBadRequest)
			json.NewEncoder(w).Encode(map[string]string{"error": "RecordNotFound"})
			return
		}
		var versions []map[string]string
		for i, editor := range editors {
			versions = append(versions, map[string]string{"changeId": fmt.Sprint("change", i), "cid": fmt.Sprint("bafyv", i), "editor": editor.String(), "createdAt": "2026-01-01T00:00:00Z"})
		}
		json.NewEncoder(w).Encode(map[string]any{"uri": uri, "versions": versions})
	default:
		http.NotFound(w, r)
	}
}

type ensureEnv struct {
	s    *Spindle
	knot *fakeKnot
	k    knotclient.Knot
}

func newEnsureEnv(t *testing.T) *ensureEnv {
	t.Helper()
	s := newWipeTestSpindle(t)
	s.wh = webhook.New(s.db, false)
	s.cfg.Server.Admission = config.AdmissionOpen
	env := &ensureEnv{s: s, knot: newFakeKnot(t)}
	env.k = knotclient.Knot{Did: syntax.DID("did:web:" + env.knot.host()), Host: env.knot.host()}

	dir := identity.NewMockDirectory()
	dir.Insert(identity.Identity{
		DID: testRepoDid,
		Services: map[string]identity.ServiceEndpoint{
			"tangled_knot": {Type: "TangledKnot", URL: env.knot.srv.URL},
		},
	})
	s.knots = knotclient.New(dir, http.DefaultClient, true)

	for _, owner := range []syntax.DID{testOwner, testNewOwner} {
		dir.Insert(identity.Identity{
			DID: owner,
			Services: map[string]identity.ServiceEndpoint{
				"atproto_pds": {Type: "AtprotoPersonalDataServer", URL: env.knot.srv.URL},
			},
		})
	}
	s.res = idresolver.NewMockResolver(dir)
	return env
}

func (e *ensureEnv) spindleDid() string {
	return e.s.cfg.Server.Did().String()
}

func (e *ensureEnv) serve(name string, collaborators ...syntax.DID) {
	e.manifest(name, e.spindleDid(), collaborators...)
	e.knot.declare(testOwner, strings.ToLower(name), testRepoDid)
}

func (e *ensureEnv) manifest(name, spindle string, collaborators ...syntax.DID) {
	e.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, manifestRecord(testOwner, name, spindle))
	for _, c := range collaborators {
		e.knot.put(testRepoDid, org_tangled.GraphCollaboratorNSID, c.String(), map[string]string{"createdAt": "2026-01-01T00:00:00Z"})
	}
}

func (e *ensureEnv) ensure(t *testing.T) *db.Repo {
	t.Helper()
	repo, err := e.s.ensureRepo(context.Background(), e.k, testRepoDid)
	if err != nil {
		t.Fatalf("ensureRepo: %v", err)
	}
	return repo
}

func (e *ensureEnv) rows(t *testing.T) []db.Repo { return repoRows(t, e.s, testRepoDid) }

func (e *ensureEnv) wantNoRows(t *testing.T) {
	t.Helper()
	if rows := e.rows(t); len(rows) != 0 {
		t.Fatalf("rows = %+v", rows)
	}
}

func (e *ensureEnv) banOwner(t *testing.T) {
	t.Helper()
	if err := e.s.db.PutBan(db.BanEntry{SubjectDid: testOwner}); err != nil {
		t.Fatal(err)
	}
}

func (e *ensureEnv) collaborator(t *testing.T, who syntax.DID) bool {
	return isCollaborator(t, e.s, testRepoDid, who)
}

func (e *ensureEnv) secrets(t *testing.T) int { return secretCount(t, e.s, testRepoDid) }

func (e *ensureEnv) addSecret(t *testing.T) { addTestSecret(t, e.s, testRepoDid) }

func TestEnsureRepoServesAManifestNamingThisSpindle(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("Test-Repo", testBob)

	repo := env.ensure(t)
	if repo == nil {
		t.Fatal("repo not served")
	}
	if repo.Owner != testOwner || repo.Rkey != "test-repo" || repo.Name != "Test-Repo" || repo.Knot != env.knot.host() {
		t.Fatalf("repo = %+v", repo)
	}
	rows := env.rows(t)
	if len(rows) != 1 || rows[0].Owner != testOwner || rows[0].Rkey != "test-repo" {
		t.Fatalf("rows = %+v", rows)
	}
	if ok, _ := env.s.e.IsRepoOwner(testOwner.String(), rbac.ThisServer, testRepoDid.String()); !ok {
		t.Error("owner has no repo policy")
	}
	if !env.collaborator(t, testBob) {
		t.Error("bob is not a collaborator")
	}
}

func TestEnsureRepoNotServedWhenTheManifestIsForSomeoneElse(t *testing.T) {
	tests := map[string]string{
		"another spindle": "did:web:other.example.com",
		"no spindle":      "",
	}
	for name, spindle := range tests {
		t.Run(name, func(t *testing.T) {
			env := newEnsureEnv(t)
			env.manifest("repo", spindle)
			env.knot.declare(testOwner, "repo", testRepoDid)

			if repo := env.ensure(t); repo != nil {
				t.Fatalf("repo = %+v, want it not served", repo)
			}
			env.wantNoRows(t)
		})
	}
}

func TestEnsureRepoForgetsARepoWithAMalformedManifestIdentity(t *testing.T) {
	tests := map[string]func(*org_tangled.RepoManifest){
		"no declaration":  func(m *org_tangled.RepoManifest) { m.Declaration = nil },
		"malformed owner": func(m *org_tangled.RepoManifest) { m.Declaration.Owner = "not a did" },
		"malformed name":  func(m *org_tangled.RepoManifest) { m.Declaration.Name = "bad/name" },
	}
	for name, corrupt := range tests {
		t.Run(name, func(t *testing.T) {
			env := newEnsureEnv(t)
			env.serve("repo")
			env.ensure(t)
			env.addSecret(t)

			m := manifestRecord(testOwner, "repo", env.spindleDid())
			corrupt(&m)
			env.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, m)

			if repo := env.ensure(t); repo != nil {
				t.Fatalf("repo = %+v, want it not served", repo)
			}
			env.wantNoRows(t)
			if env.secrets(t) != 1 {
				t.Error("a bad manifest dropped the secrets")
			}
		})
	}
}

func TestEnsureRepoForgetsARepoWhoseDeclarationPointsElsewhere(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.addSecret(t)
	env.knot.declare(testOwner, "repo", "did:plc:otherrepo")

	if env.ensure(t) != nil {
		t.Fatal("repo served with a declaration pointing at another repo")
	}
	env.wantNoRows(t)
	if env.secrets(t) != 1 {
		t.Error("a hijacked declaration dropped the secrets")
	}
}

func TestEnsureRepoKeepsAKnownRepoWhoseDeclarationIsMissing(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("old-name")
	env.ensure(t)
	env.addSecret(t)

	// manifest first, the new name can't be served until the declaration lands
	env.manifest("New-Name", env.spindleDid())
	if env.ensure(t) != nil {
		t.Fatal("a repo with no declaration was served under its new name")
	}
	rows := env.rows(t)
	if len(rows) != 1 || rows[0].Rkey != "old-name" || env.secrets(t) != 1 {
		t.Fatalf("rows = %+v, secrets = %d, want the old row untouched", rows, env.secrets(t))
	}

	env.knot.declare(testOwner, "new-name", testRepoDid)
	env.ensure(t)
	if rows := env.rows(t); len(rows) != 1 || rows[0].Rkey != "new-name" {
		t.Fatalf("rows = %+v", rows)
	}
}

func TestEnsureRepoReadsTheDeclarationOffTheLiarPdsWhenTheOwnersPdsHasNone(t *testing.T) {
	env := newEnsureEnv(t)
	env.manifest("repo", env.spindleDid())
	liar := newFakeKnot(t)
	liar.declare(testOwner, "repo", testRepoDid)

	if env.ensure(t) != nil {
		t.Fatal("served with no declaration on the owner's pds and no liar pds set")
	}
	env.s.cfg.Server.LiarPds = liar.srv.URL
	if repo := env.ensure(t); repo == nil || repo.Owner != testOwner {
		t.Fatalf("repo = %+v, want it served from the liar pds declaration", repo)
	}
}

func TestEnsureRepoTrustsTheOwnersPdsOverTheLiarPds(t *testing.T) {
	env := newEnsureEnv(t)
	env.manifest("repo", env.spindleDid())
	env.knot.declare(testOwner, "repo", "did:plc:otherrepo")
	liar := newFakeKnot(t)
	liar.declare(testOwner, "repo", testRepoDid)
	env.s.cfg.Server.LiarPds = liar.srv.URL

	if env.ensure(t) != nil {
		t.Fatal("the liar pds outvoted the owner's own declaration")
	}
	env.wantNoRows(t)
}

func TestEnsureRepoDeclarationErrorsAreNotAnAnswer(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.knot.failCollection = org_tangled.RepoDeclarationNSID

	if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
		t.Fatal("ensureRepo swallowed a pds failure")
	}
	if rows := env.rows(t); len(rows) != 1 {
		t.Fatalf("a pds outage dropped the repo, rows = %+v", rows)
	}
}

func TestEnsureRepoSyncsCollaborators(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo", testBob, testCarol)
	env.ensure(t)
	if !env.collaborator(t, testBob) || !env.collaborator(t, testCarol) {
		t.Fatal("collaborators not added")
	}

	env.knot.erase(testRepoDid, org_tangled.GraphCollaboratorNSID, testCarol.String())
	env.manifest("repo", env.spindleDid(), testDave)
	env.ensure(t)

	if !env.collaborator(t, testBob) || !env.collaborator(t, testDave) {
		t.Error("bob and dave should be collaborators")
	}
	if env.collaborator(t, testCarol) {
		t.Error("carol kept her access after the knot dropped her")
	}
	rows, err := env.s.db.ListCollaboratorsByRepoDid(testRepoDid)
	if err != nil || len(rows) != 2 {
		t.Fatalf("collaborator rows = %+v, %v", rows, err)
	}
}

func TestEnsureRepoForgetsARepoThatMoved(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo", testBob)
	env.ensure(t)
	if len(env.rows(t)) != 1 {
		t.Fatal("repo not registered")
	}
	env.addSecret(t)

	env.manifest("repo", "did:web:other.example.com", testBob)
	if env.ensure(t) != nil {
		t.Fatal("moved repo still served")
	}
	env.wantNoRows(t)
	if env.collaborator(t, testBob) {
		t.Error("collaborator acl outlived the repo")
	}
	if ok, _ := env.s.e.IsRepoOwner(testOwner.String(), rbac.ThisServer, testRepoDid.String()); ok {
		t.Error("owner acl outlived the repo")
	}
	if env.secrets(t) != 1 {
		t.Fatal("moving to another spindle dropped the secrets")
	}

	env.serve("repo", testBob)
	if env.ensure(t) == nil {
		t.Fatal("repo moved back but isn't served")
	}
	if env.secrets(t) != 1 {
		t.Error("secrets are gone after moving back")
	}
	if !env.collaborator(t, testBob) {
		t.Error("collaborator not restored after moving back")
	}
}

func TestEnsureRepoWipesARepoDeletedOnItsKnot(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.addSecret(t)

	env.knot.deleteRepo(testRepoDid)
	if env.ensure(t) != nil {
		t.Fatal("repo served without a manifest")
	}
	env.wantNoRows(t)
	if env.secrets(t) != 0 {
		t.Error("secrets of a repo the knot says is deleted were kept")
	}
}

func TestEnsureRepoKeepsTheSecretsOfARepoWhoseManifestIsMissing(t *testing.T) {
	tests := map[string]func(*fakeKnot){
		"the knot still has the repo": func(k *fakeKnot) {
			k.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
		},
		"the knot never heard of the repo": func(k *fakeKnot) {
			k.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
			k.erase(testRepoDid, org_tangled.GraphCollaboratorNSID, testBob.String())
		},
	}
	for name, lose := range tests {
		t.Run(name, func(t *testing.T) {
			env := newEnsureEnv(t)
			env.serve("repo", testBob)
			env.ensure(t)
			env.addSecret(t)

			lose(env.knot)
			if env.ensure(t) != nil {
				t.Fatal("repo served without a manifest")
			}
			env.wantNoRows(t)
			if env.secrets(t) != 1 {
				t.Error("a missing manifest dropped the secrets of a repo the knot didn't say was deleted")
			}

			env.serve("repo", testBob)
			if repo := env.ensure(t); repo == nil {
				t.Fatal("repo not served again once its manifest came back")
			}
			if env.secrets(t) != 1 {
				t.Error("secrets gone after the repo came back")
			}
		})
	}
}

func TestEnsureRepoKeepsTheRepoWhenTheKnotCantSayWhetherItWasDeleted(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.addSecret(t)

	env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
	env.knot.failStatus = true
	if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
		t.Fatal("a failing repo status was read as an answer")
	}
	if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
		t.Fatalf("a failing repo status changed the repo, rows = %+v", rows)
	}
}

func TestEnsureRepoReadsTheManifestOffTheFosterKnotWhenTheRepoKnotHasNone(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.addSecret(t)
	foster := newFakeKnot(t)
	foster.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, manifestRecord(testOwner, "repo", env.spindleDid()))
	foster.put(testRepoDid, org_tangled.GraphCollaboratorNSID, testBob.String(), map[string]string{"createdAt": "2026-01-01T00:00:00Z"})
	env.s.cfg.Server.FosterKnot = foster.srv.URL

	env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
	repo := env.ensure(t)
	if repo == nil || repo.Knot != env.knot.host() {
		t.Fatalf("repo = %+v, want it served from the foster knot's manifest", repo)
	}
	if env.secrets(t) != 1 {
		t.Error("a manifest on the foster knot didn't keep the secrets")
	}
	if !env.collaborator(t, testBob) {
		t.Error("collaborators weren't read off the foster knot")
	}
}

func TestEnsureRepoWipesARepoDeletedOnItsKnotThatTheFosterKnotDoesntHave(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.addSecret(t)
	env.s.cfg.Server.FosterKnot = newFakeKnot(t).srv.URL

	env.knot.deleteRepo(testRepoDid)
	if env.ensure(t) != nil {
		t.Fatal("repo served without a manifest")
	}
	env.wantNoRows(t)
	if env.secrets(t) != 0 {
		t.Error("secrets of a repo deleted on its knot were kept")
	}
}

func TestEnsureRepoKeepsTheRepoWhenTheFosterKnotIsDown(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.addSecret(t)
	foster := newFakeKnot(t)
	foster.failing = true
	env.s.cfg.Server.FosterKnot = foster.srv.URL

	env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
	if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
		t.Fatal("a failing foster knot was read as an answer")
	}
	if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
		t.Fatalf("a failing foster knot wiped the repo, rows = %+v", rows)
	}
}

func TestEnsureRepoKeepsARepoWhenAnotherKnotLosesTheManifest(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.addSecret(t)
	if err := env.s.db.AddRepo(db.Repo{Knot: "old-knot.example", Owner: testOwner, Rkey: "repo", RepoDid: testRepoDid}); err != nil {
		t.Fatal(err)
	}

	env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, "self")
	env.ensure(t)
	if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
		t.Fatalf("a new knot without the repo wiped it, rows = %+v", rows)
	}
}

func TestEnsureRepoKeepsAnOldRowWhoseKnotAnswersABare404(t *testing.T) {
	env := newEnsureEnv(t)
	if err := env.s.db.AddRepo(db.Repo{Knot: env.knot.host(), Owner: testOwner, Rkey: "repo", RepoDid: testRepoDid}); err != nil {
		t.Fatal(err)
	}
	env.addSecret(t)
	env.knot.bare404 = true

	if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
		t.Fatal("a bare 404 was read as an answer")
	}
	if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
		t.Fatalf("a bare 404 wiped the repo, rows = %+v", rows)
	}
}

func TestEnsureRepoKeepsTheRepoWhenTheKnotIsDown(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)

	env.addSecret(t)
	env.knot.failing = true
	if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
		t.Fatal("ensureRepo hid a knot failure")
	}
	if rows := env.rows(t); len(rows) != 1 || env.secrets(t) != 1 {
		t.Fatalf("a knot outage dropped the repo, rows = %+v", rows)
	}
}

func TestEnsureRepoRenameKeepsOneRow(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("old-name")
	env.ensure(t)

	env.serve("New-Name")
	env.ensure(t)

	rows := env.rows(t)
	if len(rows) != 1 || rows[0].Rkey != "new-name" {
		t.Fatalf("rows = %+v", rows)
	}
	if repo, err := env.s.db.GetRepoByDid(testRepoDid); err != nil || repo.Name != "New-Name" {
		t.Fatalf("repo = %+v, %v", repo, err)
	}
}

func TestEnsureRepoRefusesOwnersOutsideTheAllowList(t *testing.T) {
	env := newEnsureEnv(t)
	env.s.cfg.Server.Admission = config.AdmissionClosed
	env.serve("repo")
	if err := db.AddToAllowlist(env.s.db, "did:plc:someoneelse"); err != nil {
		t.Fatal(err)
	}

	if env.ensure(t) != nil {
		t.Fatal("repo of a non-member served")
	}
	env.wantNoRows(t)
}

func TestEnsureRepoShouldFollowAdmission(t *testing.T) {
	for _, tc := range []struct {
		name      string
		admission config.Admission
		admin     bool
		listed    bool
		banned    bool
		served    bool
	}{
		{name: "closed with an empty allow-list", admission: config.AdmissionClosed},
		{name: "closed and allow-listed", admission: config.AdmissionClosed, listed: true, served: true},
		{name: "closed and an admin", admission: config.AdmissionClosed, admin: true, served: true},
		{name: "closed and banned", admission: config.AdmissionClosed, listed: true, banned: true},
		{name: "closed and a banned admin", admission: config.AdmissionClosed, admin: true, banned: true},
		{name: "open stranger", admission: config.AdmissionOpen, served: true},
		{name: "open and banned", admission: config.AdmissionOpen, banned: true},
		{name: "open and a banned admin", admission: config.AdmissionOpen, admin: true, banned: true},
	} {
		t.Run(tc.name, func(t *testing.T) {
			env := newEnsureEnv(t)
			env.s.cfg.Server.Admission = tc.admission
			env.serve("repo")
			if tc.admin {
				env.s.cfg.Server.Admins = []string{testOwner.String()}
			}
			if tc.listed {
				if err := db.AddToAllowlist(env.s.db, testOwner); err != nil {
					t.Fatal(err)
				}
			}
			if tc.banned {
				env.banOwner(t)
			}

			if served := env.ensure(t) != nil; served != tc.served {
				t.Fatalf("served = %v, want %v, rows = %+v", served, tc.served, env.rows(t))
			}
		})
	}
}

func TestEnsureRepoRefusesBannedOwners(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.addSecret(t)
	env.banOwner(t)

	if env.ensure(t) != nil {
		t.Fatal("banned owner's repo served")
	}
	env.wantNoRows(t)
	if env.secrets(t) != 0 {
		t.Error("a ban kept the secrets")
	}
}

func TestRegisterRepoChecksTheBanAtTheCommitPoint(t *testing.T) {
	env := newEnsureEnv(t)
	env.banOwner(t)

	repo := &db.Repo{Knot: env.knot.host(), Owner: testOwner, Rkey: "repo", RepoDid: testRepoDid}
	if err := env.s.registerRepo(context.Background(), slog.Default(), repo); !errors.Is(err, errBanned) {
		t.Fatalf("registerRepo = %v, want errBanned", err)
	}
	if rows := env.rows(t); len(rows) != 0 {
		t.Fatalf("a banned owner was registered, rows = %+v", rows)
	}
}

func TestEnsureRepoTransfersToTheDeclaredOwner(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)

	env.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, manifestRecord(testNewOwner, "repo", env.spindleDid()))
	env.knot.declare(testNewOwner, "repo", testRepoDid)
	env.ensure(t)

	rows := env.rows(t)
	if len(rows) != 1 || rows[0].Owner != testNewOwner {
		t.Fatalf("rows = %+v", rows)
	}
	if ok, _ := env.s.e.IsRepoOwner(testOwner.String(), rbac.ThisServer, testRepoDid.String()); ok {
		t.Error("previous owner kept the repo policy")
	}
}

func TestBackfillOwnersBootstrapsConfiguredOwner(t *testing.T) {
	d, _ := newTestSpindleDB(t)
	owner := syntax.DID("did:plc:spindleowner")

	cfg := &config.Config{}
	cfg.Server.Owner = owner.String()
	s := &Spindle{db: d, l: slog.Default(), cfg: cfg}

	if dids, err := s.backfillOwners(); err != nil || len(dids) != 1 || dids[0] != owner {
		t.Fatalf("backfill owners = %v, %v, want [%s]", dids, err, owner)
	}
}

func TestBackfillOwnersFailsWithoutTheWholeList(t *testing.T) {
	d, _ := newTestSpindleDB(t)
	s := &Spindle{db: d, l: slog.Default(), cfg: &config.Config{}}
	d.Close()
	if dids, err := s.backfillOwners(); err == nil {
		t.Fatalf("backfill owners = %v from a closed db, want an error", dids)
	}
}
