package spindle

import (
	"context"
	"encoding/json"
	"fmt"
	"testing"

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

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

const testNewOwner = syntax.DID("did:plc:newowner")

// the same call manual triggers and pull request pipelines use
func (e *ensureEnv) canPush(t *testing.T, who syntax.DID) bool {
	t.Helper()
	ok, err := e.s.e.IsPushAllowed(who.String(), rbac.ThisServer, testRepoDid.String())
	if err != nil {
		t.Fatal(err)
	}
	return ok
}

func (e *ensureEnv) grant(who syntax.DID) {
	e.knot.put(testRepoDid, org_tangled.GraphCollaboratorNSID, who.String(), map[string]string{"createdAt": "2026-01-01T00:00:00Z"})
}

func (e *ensureEnv) revoke(who syntax.DID) {
	e.knot.erase(testRepoDid, org_tangled.GraphCollaboratorNSID, who.String())
}

func (e *ensureEnv) wantPush(t *testing.T, who syntax.DID, want bool) {
	t.Helper()
	if got := e.canPush(t, who); got != want {
		t.Errorf("IsPushAllowed(%s) = %v, want %v", who, got, want)
	}
}

func (e *ensureEnv) wantNoPushFor(t *testing.T, who ...syntax.DID) {
	t.Helper()
	for _, w := range who {
		e.wantPush(t, w, false)
	}
}

func (e *ensureEnv) collaboratorRows(t *testing.T) []db.RepoCollaborator {
	t.Helper()
	rows, err := e.s.db.ListCollaboratorsByRepoDid(testRepoDid)
	if err != nil {
		t.Fatal(err)
	}
	return rows
}

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

func collaboratorCommit(repo syntax.DID, rkey syntax.DID, operation string) *jetstream.Event {
	return &jetstream.Event{
		Did:  repo.String(),
		Kind: jetstream.EventKindCommit,
		Commit: &jetstream.Commit{
			Operation:  operation,
			Collection: org_tangled.GraphCollaboratorNSID,
			RKey:       rkey.String(),
			Record:     json.RawMessage(`{}`),
		},
	}
}

func (e *ensureEnv) ingest(t *testing.T, event *jetstream.Event) {
	t.Helper()
	if err := e.s.ingest()(context.Background(), event); err != nil {
		t.Fatal(err)
	}
}

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

	env.wantPush(t, testOwner, true)
	env.wantPush(t, testBob, true)
	env.wantNoPushFor(t, testCarol, "did:plc:nobody", "")

	env.revoke(testBob)
	env.ensure(t)
	env.wantPush(t, testOwner, true)
	env.wantNoPushFor(t, testBob, testCarol)
}

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

	env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationCreate))

	env.wantNoPushFor(t, testBob)
	env.wantPush(t, testOwner, true)
	env.wantNoCollaboratorRows(t)
}

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

	env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationDelete))

	env.wantPush(t, testBob, true)
	if rows := env.collaboratorRows(t); len(rows) != 1 {
		t.Fatalf("collaborator rows = %+v", rows)
	}
}

func TestCollaboratorEventsFromNonRepoDidsGrantNothing(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	before := env.knot.count()

	// anyone can write this record into their own repo, only the served repo's did counts
	events := map[string]*jetstream.Event{
		"naming themselves":      collaboratorCommit(testBob, testBob, jetstream.CommitOperationCreate),
		"naming the served repo": collaboratorCommit(testBob, testRepoDid, jetstream.CommitOperationCreate),
		"from the owner's pds":   collaboratorCommit(testOwner, testBob, jetstream.CommitOperationCreate),
		"naming someone else":    collaboratorCommit(testCarol, testBob, jetstream.CommitOperationUpdate),
	}
	for name, event := range events {
		t.Run(name, func(t *testing.T) {
			env.ingest(t, event)
			env.wantNoPushFor(t, testBob, testCarol, testRepoDid)
		})
	}
	env.wantNoCollaboratorRows(t)
	if got := env.knot.count(); got != before {
		t.Errorf("events for repos spindle doesn't serve cost %d knot requests", got-before)
	}
}

func TestCollaboratorGrantAndRemovalReachPushAccessThroughTheIngester(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	env.wantNoPushFor(t, testBob)

	env.grant(testBob)
	env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationCreate))
	env.wantPush(t, testBob, true)
	env.wantNoPushFor(t, testCarol)

	env.revoke(testBob)
	env.ingest(t, collaboratorCommit(testRepoDid, testBob, jetstream.CommitOperationDelete))
	env.wantNoPushFor(t, testBob)
	env.wantNoCollaboratorRows(t)
	env.wantPush(t, testOwner, true)
}

func TestForgettingOrWipingARepoTakesPushAccessFromEveryone(t *testing.T) {
	tests := map[string]func(*testing.T, *ensureEnv){
		"manifest names another spindle": func(t *testing.T, env *ensureEnv) {
			env.manifest("repo", "did:web:other.example.com")
		},
		"declaration points elsewhere": func(t *testing.T, env *ensureEnv) {
			env.knot.declare(testOwner, "repo", "did:plc:otherrepo")
		},
		"manifest has no declaration": func(t *testing.T, env *ensureEnv) {
			m := manifestRecord(testOwner, "repo", env.spindleDid())
			m.Declaration = nil
			env.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, m)
		},
		"manifest deleted": func(t *testing.T, env *ensureEnv) {
			env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey)
		},
		"owner banned": func(t *testing.T, env *ensureEnv) {
			env.banOwner(t)
		},
	}
	for name, leave := range tests {
		t.Run(name, func(t *testing.T) {
			env := newEnsureEnv(t)
			env.serve("repo", testBob, testCarol)
			env.ensure(t)
			env.wantPush(t, testOwner, true)
			env.wantPush(t, testBob, true)

			leave(t, env)
			if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err != nil {
				t.Fatal(err)
			}
			env.wantNoPushFor(t, testOwner, testBob, testCarol)
			env.requireNewRepoStartsClean(t)
		})
	}

	t.Run("wiped directly", func(t *testing.T) {
		env := newEnsureEnv(t)
		env.serve("repo", testBob, testCarol)
		env.ensure(t)

		if err := env.s.WipeRepo(context.Background(), testRepoDid, "test"); err != nil {
			t.Fatal(err)
		}
		env.wantNoPushFor(t, testOwner, testBob, testCarol)
		env.requireNewRepoStartsClean(t)
	})
}

// a new repo at the same did must not inherit the old owner or collaborators
func (e *ensureEnv) requireNewRepoStartsClean(t *testing.T) {
	t.Helper()
	e.revoke(testBob)
	e.revoke(testCarol)
	e.knot.put(testRepoDid, org_tangled.RepoManifestNSID, knotclient.ManifestRkey, manifestRecord(testNewOwner, "repo", e.spindleDid()))
	e.knot.declare(testNewOwner, "repo", testRepoDid)
	e.ensure(t)

	e.wantPush(t, testNewOwner, true)
	e.wantNoPushFor(t, testOwner, testBob, testCarol)
	e.wantNoCollaboratorRows(t)
}

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

	// the cap is maxListPages of 100 each, and bob is not in this list
	const flood = 3300
	for i := range flood {
		env.grant(syntax.DID(fmt.Sprintf("did:plc:flood%d", i)))
	}
	env.revoke(testBob)

	if _, err := env.s.ensureRepo(context.Background(), env.k, testRepoDid); err == nil {
		t.Fatal("ensureRepo accepted a list it had to cut")
	}
	env.wantPush(t, testBob, true)
	env.wantPush(t, testOwner, true)
	env.wantNoPushFor(t, "did:plc:flood0", "did:plc:flood3299")
	if rows := env.collaboratorRows(t); len(rows) != 1 || rows[0].Subject != testBob {
		t.Fatalf("collaborator rows = %+v, want only bob", rows)
	}
}

func TestCollaboratorEventWithAnOversizedListGoesInTheRetrySet(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo", testBob)
	env.ensure(t)
	for i := range 3300 {
		env.grant(syntax.DID(fmt.Sprintf("did:plc:flood%d", i)))
	}

	env.ingest(t, collaboratorCommit(testRepoDid, "did:plc:flood0", jetstream.CommitOperationCreate))

	if attempts, err := env.s.db.RepoRetryAttempts(testRepoDid); err != nil || attempts != 1 {
		t.Fatalf("retry attempts = %d, %v, want the repo in the retry set", attempts, err)
	}
	env.wantPush(t, testBob, true)
	env.wantNoPushFor(t, "did:plc:flood0")
}
