package spindle

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

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

	"tangled.org/core/api/org_tangled"
	"tangled.org/core/jetstream"
	"tangled.org/core/spindle/config"
	"tangled.org/core/spindle/db"
	"tangled.org/core/tapc"
)

func recordEvent(did syntax.DID, collection, rkey string, action tapc.RecordAction, record any) tapc.Event {
	raw, _ := json.Marshal(record)
	return tapc.Event{
		Type: tapc.EvtRecord,
		Record: &tapc.RecordEventData{
			Live:       true,
			Did:        did,
			Collection: syntax.NSID(collection),
			Rkey:       syntax.RecordKey(rkey),
			Action:     action,
			Record:     raw,
		},
	}
}

func declarationEvent(owner, repo syntax.DID, action tapc.RecordAction) tapc.Event {
	return recordEvent(owner, org_tangled.RepoDeclarationNSID, "repo", action, org_tangled.RepoDeclaration{Repo: repo.String()})
}

func processOK(t *testing.T, env *ensureEnv, evt tapc.Event) {
	t.Helper()
	if err := env.s.processEvent(context.Background(), evt); err != nil {
		t.Fatal(err)
	}
}

func TestProcessEventIgnoresOtherCollections(t *testing.T) {
	env := newEnsureEnv(t)
	for _, collection := range []string{org_tangled.FeedStarNSID, org_tangled.FeedCommentNSID, org_tangled.FeedReactionNSID, org_tangled.FeedSubscriptionNSID} {
		evt := recordEvent("did:plc:someone", collection, "3mrhpypucbsg4", tapc.RecordCreateAction, map[string]string{"title": "x"})
		if err := env.s.processEvent(context.Background(), evt); err != nil {
			t.Fatalf("processEvent(%s): %v", collection, err)
		}
	}
	if env.knot.count() != 0 {
		t.Fatalf("knot was asked %d times for records spindle no longer reads", env.knot.count())
	}
}

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

	other := org_tangled.RepoManifest{Spindle: ptr("did:web:other.example.com")}
	evt := recordEvent(testRepoDid, org_tangled.RepoManifestNSID, "self", tapc.RecordUpdateAction, other)
	processOK(t, env, evt)
	if env.knot.count() != 0 || len(env.rows(t)) != 0 {
		t.Fatalf("a manifest for another spindle cost %d knot requests, rows %+v", env.knot.count(), env.rows(t))
	}

	processOK(t, env, markEvent(testRepoDid, env.spindleDid(), testOwner))
	if rows := env.rows(t); len(rows) != 1 {
		t.Fatalf("rows = %+v", rows)
	}
}

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

	// the event body is empty on purpose, the knot's manifest is what counts
	env.manifest("repo", "did:web:other.example.com")
	evt := recordEvent(testRepoDid, org_tangled.RepoManifestNSID, "self", tapc.RecordUpdateAction, org_tangled.RepoManifest{})
	processOK(t, env, evt)
	if rows := env.rows(t); len(rows) != 0 {
		t.Fatalf("moved repo kept, rows = %+v", rows)
	}
}

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

	env.knot.erase(testRepoDid, org_tangled.RepoManifestNSID, "self")
	evt := recordEvent(testRepoDid, org_tangled.RepoManifestNSID, "self", tapc.RecordDeleteAction, nil)
	processOK(t, env, evt)
	if rows := env.rows(t); len(rows) != 0 {
		t.Fatalf("rows = %+v", rows)
	}
}

func TestCollaboratorEventsOnlyMatterForKnownRepos(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo", testBob)
	evt := recordEvent(testRepoDid, org_tangled.GraphCollaboratorNSID, testBob.String(), tapc.RecordCreateAction, map[string]string{})

	processOK(t, env, evt)
	if env.knot.count() != 0 {
		t.Fatalf("collaborator of an unknown repo cost %d knot requests", env.knot.count())
	}

	env.ensure(t)
	env.knot.erase(testRepoDid, org_tangled.GraphCollaboratorNSID, testBob.String())
	evt = recordEvent(testRepoDid, org_tangled.GraphCollaboratorNSID, testBob.String(), tapc.RecordDeleteAction, nil)
	processOK(t, env, evt)
	if env.collaborator(t, testBob) {
		t.Error("bob kept access after his collaborator record went away")
	}
}

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

	evt := declarationEvent(testOwner, testRepoDid, tapc.RecordCreateAction)
	processOK(t, env, evt)
	if rows := env.rows(t); len(rows) != 1 {
		t.Fatalf("a declaration backfill didn't register the repo, rows = %+v", rows)
	}

	env.knot.undeclare(testOwner, "repo")
	evt = recordEvent(testOwner, org_tangled.RepoDeclarationNSID, "repo", tapc.RecordDeleteAction, nil)
	processOK(t, env, evt)
	if rows := env.rows(t); len(rows) != 1 {
		t.Fatalf("deleting the declaration forgot the repo, rows = %+v", rows)
	}
}

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

	evt := recordEvent(testOwner, org_tangled.RepoDeclarationNSID, "repo", tapc.RecordDeleteAction, nil)
	processOK(t, env, evt)
	if env.knot.count() != 0 {
		t.Fatalf("a legacy row cost %d knot requests", env.knot.count())
	}
}

func TestDeclarationFromAnOutsiderCostsNothing(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)
	}

	evt := declarationEvent(testOwner, testRepoDid, tapc.RecordCreateAction)
	processOK(t, env, evt)
	if env.knot.count() != 0 {
		t.Fatalf("an owner outside the allow-list cost %d knot requests", env.knot.count())
	}
}

func TestDeclarationFromAnAdminIsServedWhileClosed(t *testing.T) {
	env := newEnsureEnv(t)
	env.s.cfg.Server.Admission = config.AdmissionClosed
	env.s.cfg.Server.Admins = []string{testOwner.String()}
	env.serve("repo")

	evt := declarationEvent(testOwner, testRepoDid, tapc.RecordCreateAction)
	processOK(t, env, evt)
	if rows := env.rows(t); len(rows) != 1 {
		t.Fatalf("an admin's declaration wasn't served, rows = %+v", rows)
	}
}

func TestJetstreamToTapEventMarksRecordsLive(t *testing.T) {
	tests := []struct {
		name      string
		operation string
		action    tapc.RecordAction
	}{
		{name: "create", operation: jetstream.CommitOperationCreate, action: tapc.RecordCreateAction},
		{name: "update", operation: jetstream.CommitOperationUpdate, action: tapc.RecordUpdateAction},
		{name: "delete", operation: jetstream.CommitOperationDelete, action: tapc.RecordDeleteAction},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			event, ok := jetstreamToTapEvent(&jetstream.Event{
				Did:  "did:plc:jge3zxi7lgrfnvhzcgrimeo7",
				Kind: jetstream.EventKindCommit,
				Commit: &jetstream.Commit{
					Operation:  tt.operation,
					Collection: org_tangled.RepoManifestNSID,
					RKey:       "self",
					Record:     json.RawMessage(`{"declaration":{}}`),
				},
			})
			if !ok {
				t.Fatal("jetstreamToTapEvent() rejected a valid event")
			}
			if event.Record == nil || !event.Record.Live {
				t.Fatalf("converted event = %+v, want a live record", event.Record)
			}
			if event.Record.Collection.String() != org_tangled.RepoManifestNSID {
				t.Errorf("collection = %q", event.Record.Collection)
			}
			if event.Record.Action != tt.action {
				t.Errorf("action = %q, want %q", event.Record.Action, tt.action)
			}
		})
	}
}

func manifestCommit(env *ensureEnv) *jetstream.Event {
	raw, _ := json.Marshal(org_tangled.RepoManifest{Spindle: ptr(env.spindleDid()), Declaration: &org_tangled.RepoManifest_Declaration{Owner: testOwner.String(), Name: "repo"}})
	return &jetstream.Event{
		Did:  testRepoDid.String(),
		Kind: jetstream.EventKindCommit,
		Commit: &jetstream.Commit{
			Operation:  jetstream.CommitOperationCreate,
			Collection: org_tangled.RepoManifestNSID,
			RKey:       "self",
			Record:     raw,
		},
	}
}

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

	if err := env.s.ingest()(context.Background(), manifestCommit(env)); err != nil {
		t.Fatal(err)
	}
	if rows := env.rows(t); len(rows) != 1 || !env.collaborator(t, testBob) {
		t.Fatalf("jetstream commit didn't register the repo, rows = %+v", rows)
	}
}

func TestDeclarationArrivingAfterTheManifestEnrollsTheRepo(t *testing.T) {
	env := newEnsureEnv(t)
	env.manifest("repo", env.spindleDid())

	processOK(t, env, markEvent(testRepoDid, env.spindleDid(), testOwner))
	if rows := env.rows(t); len(rows) != 0 {
		t.Fatalf("repo served before its owner declared it, rows = %+v", rows)
	}

	env.knot.declare(testOwner, "repo", testRepoDid)
	processOK(t, env, declarationEvent(testOwner, testRepoDid, tapc.RecordCreateAction))
	if rows := env.rows(t); len(rows) != 1 {
		t.Fatalf("the declaration didn't enrol the repo, rows = %+v", rows)
	}
}

func TestIngestReturnsAnEventCutByShutdownSoTheCursorStaysBehindIt(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo", testBob)
	event := manifestCommit(env)

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	if err := env.s.ingest()(ctx, event); err == nil {
		t.Fatal("an event cut by shutdown was reported handled")
	}
	if rows := env.rows(t); len(rows) != 0 {
		t.Fatalf("a cancelled event still registered the repo: %+v", rows)
	}
	if err := env.s.ingest()(context.Background(), event); err != nil {
		t.Fatal(err)
	}
}

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

	evt := recordEvent("did:plc:newowner", org_tangled.RepoDeclarationNSID, "whatever", tapc.RecordCreateAction, org_tangled.RepoDeclaration{Repo: testRepoDid.String()})
	processOK(t, env, evt)
	if got := env.knot.count(); got != before {
		t.Fatalf("a stranger's declaration cost %d knot requests", got-before)
	}

	evt = declarationEvent(testOwner, testRepoDid, tapc.RecordUpdateAction)
	processOK(t, env, evt)
	if env.knot.count() == before {
		t.Error("the owner's own declaration no longer refreshes the repo")
	}
}

func TestAMarkThatFailsOnTheDbIsReportedInsteadOfSwallowed(t *testing.T) {
	env := newEnsureEnv(t)
	env.serve("repo")
	env.ensure(t)
	if _, err := env.s.db.Exec(`drop table record_retries`); err != nil {
		t.Fatal(err)
	}
	event := &jetstream.Event{Did: testRepoDid.String(), Kind: jetstream.EventKindCommit, Commit: &jetstream.Commit{
		Operation: jetstream.CommitOperationCreate, Collection: org_tangled.TrackTicketNSID, RKey: "3kticket00001", Record: []byte("{}"),
	}}

	if err := env.s.ingestEvent(withMarkOnly(context.Background()), event); err == nil {
		t.Fatal("an event whose mark couldn't be written was reported as handled")
	}
}
