package spindle

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"tangled.org/core/jetstream"
	"tangled.org/core/repoident"
	"tangled.org/core/repoverify"

	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/stretchr/testify/assert"
	"tangled.org/core/api/tangled"
	"tangled.org/core/eventconsumer"
	"tangled.org/core/idresolver"
	"tangled.org/core/spindle/config"
	"tangled.org/core/spindle/db"

	"tangled.org/core/tapc"
)

type mockDirectory struct {
	ident *identity.Identity
}

func (m *mockDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) {
	return m.ident, nil
}

func (m *mockDirectory) LookupHandle(ctx context.Context, handle syntax.Handle) (*identity.Identity, error) {
	return m.ident, nil
}

func (m *mockDirectory) Lookup(ctx context.Context, id syntax.AtIdentifier) (*identity.Identity, error) {
	return m.ident, nil
}

func (m *mockDirectory) Purge(ctx context.Context, id syntax.AtIdentifier) error {
	return nil
}

func mockRepoVerifier(res repoverify.Result) repoverify.Verifier {
	return func(ctx context.Context, repoDid repoident.RepoDid) (repoverify.Result, error) {
		return res, nil
	}
}

func TestProcessRepo_MembershipChange(t *testing.T) {
	member := syntax.DID("did:example:foo")

	var ok bool
	var err error
	d, _ := newTestSpindleDB(t)

	ok, err = d.IsAllowedMember(t.Context(), member, false)
	assert.NoError(t, err)
	assert.False(t, ok)

	ok, err = d.IsAllowedMember(t.Context(), member, true)
	assert.NoError(t, err)
	assert.True(t, ok)

	assert.NoError(t, d.AllowMember(t.Context(), member))

	ok, err = d.IsAllowedMember(t.Context(), member, false)
	assert.NoError(t, err)
	assert.True(t, ok)

	ok, err = d.IsAllowedMember(t.Context(), member, true)
	assert.NoError(t, err)
	assert.True(t, ok)
}

func TestProcessRepo_MembershipCheck(t *testing.T) {
	d, e := newTestSpindleDB(t)

	cfg := &config.Config{}
	cfg.Server.Hostname = "spindle.test"
	cfg.Server.InviteOnly = true

	ccfg := eventconsumer.NewConsumerConfig()
	ccfg.Logger = slog.Default()
	ks := eventconsumer.NewConsumer(*ccfg)

	jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
	if jcerr != nil {
		t.Fatalf("NewJetstreamClient: %v", jcerr)
	}
	s := &Spindle{
		db:      d,
		e:       e,
		l:       slog.Default(),
		cfg:     cfg,
		ks:      ks,
		jc:      jc,
		rootCtx: context.Background(),
		verify: mockRepoVerifier(repoverify.Result{
			RepoDid:  "did:plc:testrepo123",
			OwnerDid: "did:plc:memberowner",
			Rkey:     "test-repo-rkey",
			KnotURL: func() *url.URL {
				u, err := url.Parse("https://knot.test")
				assert.NoError(t, err)
				return u
			}(),
		}),
	}

	tap := &Tap{
		spindle: s,
		logger:  slog.Default(),
	}

	ownerDid := syntax.DID("did:plc:memberowner")
	nonMemberDid := syntax.DID("did:plc:nonmemberowner")
	repoDid := syntax.DID("did:plc:testrepo123")

	err := d.AllowMember(t.Context(), ownerDid)
	if err != nil {
		t.Fatalf("AddSpindleMember: %v", err)
	}

	recNonMember := tangled.Repo{
		Knot:      "knot.test",
		RepoDid:   (*string)(&repoDid),
		Spindle:   &cfg.Server.Hostname,
		CreatedAt: time.Now().Format(time.RFC3339),
	}
	recNonMemberJson, _ := json.Marshal(recNonMember)

	err = tap.processRepo(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        nonMemberDid,
		Rkey:       "test-repo-rkey",
		Collection: syntax.NSID(tangled.RepoNSID),
		Action:     tapc.RecordCreateAction,
		Record:     recNonMemberJson,
	})
	if err != nil {
		t.Fatalf("processRepo returned error for non-member: %v", err)
	}

	_, err = d.GetRepoByDid(repoDid)
	if err == nil {
		t.Fatal("repo for non-member was registered in DB, expected rejection")
	}

	recMember := tangled.Repo{
		Knot:      "knot.test",
		RepoDid:   (*string)(&repoDid),
		Spindle:   &cfg.Server.Hostname,
		CreatedAt: time.Now().Format(time.RFC3339),
	}
	recMemberJson, _ := json.Marshal(recMember)

	err = tap.processRepo(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        ownerDid,
		Rkey:       "test-repo-rkey",
		Collection: syntax.NSID(tangled.RepoNSID),
		Action:     tapc.RecordCreateAction,
		Record:     recMemberJson,
	})
	if err == nil {
		t.Fatal("expected git clone error for valid member, but got nil")
	}

	if !strings.Contains(err.Error(), "setting up sparse-clone git repo") {
		t.Fatalf("expected sparse-clone error, got: %v", err)
	}
}

func TestProcessPull_IsCollaboratorCheck(t *testing.T) {
	d, e := newTestSpindleDB(t)

	cfg := &config.Config{}
	cfg.Server.Hostname = "spindle.test"

	jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
	if jcerr != nil {
		t.Fatalf("NewJetstreamClient: %v", jcerr)
	}
	s := &Spindle{
		db:      d,
		e:       e,
		l:       slog.Default(),
		cfg:     cfg,
		res:     idresolver.DefaultResolver("https://plc.test"),
		jc:      jc,
		rootCtx: context.Background(),
		verify: mockRepoVerifier(repoverify.Result{
			RepoDid:  "did:plc:testrepo123",
			OwnerDid: "did:plc:repoowner",
			Rkey:     "test-repo-rkey",
			KnotURL: func() *url.URL {
				u, _ := url.Parse("knot.test")
				return u
			}(),
		}),
	}

	repoOwnerDid := syntax.DID("did:plc:repoowner")
	nonPusherDid := syntax.DID("did:plc:nonpusher")
	pusherDid := syntax.DID("did:plc:pusher")
	repoDid := syntax.DID("did:plc:testrepo123")

	err := d.UpsertRepo(db.Repo{
		Knot:      "knot.test",
		Owner:     repoOwnerDid,
		Rkey:      "test-repo-rkey",
		RepoDid:   repoDid,
		CreatedAt: time.Now().Format(time.RFC3339),
	})
	if err != nil {
		t.Fatalf("AddRepo: %v", err)
	}

	err = e.SetRepoOwner(repoOwnerDid, repoDid)
	if err != nil {
		t.Fatalf("AddRepo permissions: %v", err)
	}
	err = e.AddRepoCollaborator(pusherDid, repoDid)
	if err != nil {
		t.Fatalf("AddCollaborator: %v", err)
	}

	pullRecord := tangled.RepoPull{
		Target: &tangled.RepoPull_Target{
			Branch: "main",
			Repo:   repoDid.String(),
		},
		Source: &tangled.RepoPull_Source{
			Branch: "feature",
			Repo:   nil, // branch-based PR (source repo is nil)
		},
	}
	pullRecordJson, _ := json.Marshal(pullRecord)

	err = s.processPull(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        nonPusherDid,
		Rkey:       "pull-rkey-1",
		Collection: syntax.NSID(tangled.RepoPullNSID),
		Action:     tapc.RecordCreateAction,
		Record:     pullRecordJson,
	})
	if err != nil {
		t.Fatalf("processPull returned error for non-pusher: %v", err)
	}

	// fetch fails because plc/pds are not real
	err = s.processPull(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        pusherDid,
		Rkey:       "pull-rkey-2",
		Collection: syntax.NSID(tangled.RepoPullNSID),
		Action:     tapc.RecordCreateAction,
		Record:     pullRecordJson,
	})
	if err == nil {
		t.Fatal("expected error from fetchLatestSubmission for valid pusher, but got nil")
	}

	if !strings.Contains(err.Error(), "checking push access") && !strings.Contains(err.Error(), "resolve PR owner") && !strings.Contains(err.Error(), "invalid memory address") {
		t.Fatalf("expected failed identity resolution or connection error, got: %v", err)
	}
}

func TestProcessRepo_HijackRepoDidCheck(t *testing.T) {
	d, e := newTestSpindleDB(t)

	cfg := &config.Config{}
	cfg.Server.Hostname = "spindle.test"

	ccfg := eventconsumer.NewConsumerConfig()
	ccfg.Logger = slog.Default()
	ks := eventconsumer.NewConsumer(*ccfg)

	jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
	if jcerr != nil {
		t.Fatalf("NewJetstreamClient: %v", jcerr)
	}
	s := &Spindle{
		db:      d,
		e:       e,
		l:       slog.Default(),
		cfg:     cfg,
		ks:      ks,
		jc:      jc,
		rootCtx: context.Background(),
		verify: mockRepoVerifier(repoverify.Result{
			RepoDid:  "did:plc:sharedrepo",
			OwnerDid: "did:plc:alice",
			Rkey:     "alice-repo",
			KnotURL: func() *url.URL {
				u, _ := url.Parse("knot.test")
				return u
			}(),
		}),
	}

	tap := &Tap{
		spindle: s,
		logger:  slog.Default(),
	}

	aliceDid := syntax.DID("did:plc:alice")
	bobDid := syntax.DID("did:plc:bob")
	repoDid := syntax.DID("did:plc:sharedrepo")

	err := d.AllowMember(t.Context(), aliceDid)
	if err != nil {
		t.Fatalf("AddSpindleMember alice: %v", err)
	}
	err = d.AllowMember(t.Context(), bobDid)
	if err != nil {
		t.Fatalf("AddSpindleMember bob: %v", err)
	}

	err = d.UpsertRepo(db.Repo{
		Knot:      "knot.test",
		Owner:     aliceDid,
		Rkey:      "alice-repo",
		RepoDid:   syntax.DID(repoDid),
		CreatedAt: time.Now().Format(time.RFC3339),
	})
	if err != nil {
		t.Fatalf("d.AddRepo: %v", err)
	}

	if err := e.SetRepoOwner(aliceDid, repoDid); err != nil {
		t.Fatalf("SetRepoOwner: %v", err)
	}

	// bob tries to register alice's repo did, must reject the hijack
	recBob := tangled.Repo{
		Knot:      "knot.test",
		RepoDid:   (*string)(&repoDid),
		Spindle:   &cfg.Server.Hostname,
		CreatedAt: time.Now().Format(time.RFC3339),
	}
	recBobJson, _ := json.Marshal(recBob)

	err = tap.processRepo(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        bobDid,
		Rkey:       "bob-repo",
		Collection: syntax.NSID(tangled.RepoNSID),
		Action:     tapc.RecordCreateAction,
		Record:     recBobJson,
	})
	if err != nil {
		t.Fatalf("processRepo returned error on duplicate repoDid hijack attempt: %v", err)
	}

	stored, err := d.GetRepoByDid(repoDid)
	if err != nil {
		t.Fatalf("alice's repo row was destroyed by bob's hijack attempt: %v", err)
	}
	if stored.Owner != aliceDid || stored.Rkey != "alice-repo" {
		t.Fatalf("bob hijacked alice's repoDid: owner=%s rkey=%s", stored.Owner, stored.Rkey)
	}

	// bob points the same repoDid at another spindle, which must not tear alice's repo down
	otherSpindle := "other.test"
	recTeardown := tangled.Repo{
		Knot:      "knot.test",
		RepoDid:   (*string)(&repoDid),
		Spindle:   &otherSpindle,
		CreatedAt: time.Now().Format(time.RFC3339),
	}
	recTeardownJson, _ := json.Marshal(recTeardown)

	err = tap.processRepo(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        bobDid,
		Rkey:       "bob-repo",
		Collection: syntax.NSID(tangled.RepoNSID),
		Action:     tapc.RecordUpdateAction,
		Record:     recTeardownJson,
	})
	if err != nil {
		t.Fatalf("processRepo returned error on forged teardown: %v", err)
	}

	if _, err := d.GetRepoByDid(repoDid); err != nil {
		t.Fatalf("bob tore down alice's repo by naming her repoDid: %v", err)
	}
	ok, err := e.IsRepoOwner(aliceDid, repoDid)
	if err != nil || !ok {
		t.Fatal("bob's forged teardown removed alice's owner policy")
	}
}

func TestTeardownRepo_RBAC(t *testing.T) {
	d, e := newTestSpindleDB(t)

	cfg := &config.Config{}
	cfg.Server.Hostname = "spindle.test"

	jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
	if jcerr != nil {
		t.Fatalf("NewJetstreamClient: %v", jcerr)
	}
	s := &Spindle{
		db:      d,
		e:       e,
		l:       slog.Default(),
		cfg:     cfg,
		jc:      jc,
		rootCtx: context.Background(),
		verify: mockRepoVerifier(repoverify.Result{
			RepoDid:  "did:plc:testrepo123",
			OwnerDid: "did:plc:repoowner",
			Rkey:     "test-repo-rkey",
			KnotURL: func() *url.URL {
				u, _ := url.Parse("knot.test")
				return u
			}(),
		}),
	}

	tap := &Tap{
		spindle: s,
		logger:  slog.Default(),
	}

	ownerDid := syntax.DID("did:plc:repoowner")
	repoDid := syntax.DID("did:plc:testrepo123")
	collabDid := syntax.DID("did:plc:collab")

	err := d.UpsertRepo(db.Repo{
		Knot:      "knot.test",
		Owner:     ownerDid,
		Rkey:      "test-repo-rkey",
		RepoDid:   repoDid,
		CreatedAt: time.Now().Format(time.RFC3339),
	})
	if err != nil {
		t.Fatalf("AddRepo DB: %v", err)
	}

	err = e.SetRepoOwner(ownerDid, repoDid)
	if err != nil {
		t.Fatalf("AddRepo policy: %v", err)
	}

	err = e.AddRepoCollaborator(collabDid, repoDid)
	if err != nil {
		t.Fatalf("AddCollaborator policy: %v", err)
	}

	err = tap.processRepo(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        ownerDid,
		Rkey:       "test-repo-rkey",
		Collection: syntax.NSID(tangled.RepoNSID),
		Action:     tapc.RecordDeleteAction,
	})
	if err != nil {
		t.Fatalf("processRepo delete returned error: %v", err)
	}

	_, err = d.GetRepoByDid(repoDid)
	if err == nil {
		t.Fatal("repo remained in DB after delete")
	}

	ok, err := e.IsRepoOwner(ownerDid, repoDid)
	if err != nil || ok {
		t.Fatal("repo owner policy remained in Casbin after delete")
	}

	ok, err = e.IsRepoCollaborator(collabDid, repoDid)
	if err != nil || ok {
		t.Fatal("collaborator policy remained in Casbin after delete")
	}
}

func TestProcessRepo_ForgeDeleteRejection(t *testing.T) {
	d, e := newTestSpindleDB(t)

	cfg := &config.Config{}
	cfg.Server.Hostname = "spindle.test"

	jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
	if jcerr != nil {
		t.Fatalf("NewJetstreamClient: %v", jcerr)
	}

	s := &Spindle{
		db:      d,
		e:       e,
		l:       slog.Default(),
		cfg:     cfg,
		jc:      jc,
		rootCtx: context.Background(),
		verify: mockRepoVerifier(repoverify.Result{
			RepoDid:  "did:plc:sharedrepo",
			OwnerDid: "did:plc:alice",
			Rkey:     "test-repo-rkey",
			KnotURL: func() *url.URL {
				u, _ := url.Parse("knot.test")
				return u
			}(),
		}),
	}

	tap := &Tap{
		spindle: s,
		logger:  slog.Default(),
	}

	aliceDid := syntax.DID("did:plc:alice")
	bobDid := syntax.DID("did:plc:bob")
	repoDid := syntax.DID("did:plc:sharedrepo")

	err := d.UpsertRepo(db.Repo{
		Knot:      "knot.test",
		Owner:     aliceDid,
		Rkey:      "test-repo-rkey",
		RepoDid:   repoDid,
		CreatedAt: time.Now().Format(time.RFC3339),
	})
	if err != nil {
		t.Fatalf("AddRepo DB: %v", err)
	}

	err = e.SetRepoOwner(aliceDid, repoDid)
	if err != nil {
		t.Fatalf("AddRepo policy: %v", err)
	}

	// bob tries to delete alice's repo, must reject forged delete
	err = tap.processRepo(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        bobDid,
		Rkey:       "test-repo-rkey",
		Collection: syntax.NSID(tangled.RepoNSID),
		Action:     tapc.RecordDeleteAction,
	})
	if err != nil {
		t.Fatalf("processRepo returned error on delete: %v", err)
	}

	_, err = d.GetRepoByDid(repoDid)
	if err != nil {
		t.Fatalf("Alice's repo was deleted or error: %v", err)
	}

	ok, err := e.IsRepoOwner(aliceDid, repoDid)
	if err != nil || !ok {
		t.Fatal("Alice's owner policy was removed from Casbin by forged delete")
	}
}

func TestReconcileCollaborators(t *testing.T) {
	d, e := newTestSpindleDB(t)

	ownerDid := syntax.DID("did:plc:owner")
	repoDid := syntax.DID("did:plc:repo")
	staleDid := syntax.DID("did:plc:stale")
	keptDid := syntax.DID("did:plc:kept")
	newDid := syntax.DID("did:plc:new")

	if err := e.SetRepoOwner(ownerDid, repoDid); err != nil {
		t.Fatalf("SetRepoOwner: %v", err)
	}
	// spindle's view: one collaborator the knot dropped, one it still has
	for _, did := range []syntax.DID{staleDid, keptDid} {
		if err := e.AddRepoCollaborator(did, repoDid); err != nil {
			t.Fatalf("AddRepoCollaborator(%s): %v", did, err)
		}
	}

	// the knot's view: keptDid and a collaborator spindle never saw. Paginated, and it lists
	// the owner too - reconcile must not treat that as an explicit grant to remove.
	pages := [][]string{
		{ownerDid.String(), keptDid.String()},
		{newDid.String()},
	}
	var gotSubject string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/xrpc/"+tangled.RepoListCollaboratorsNSID {
			http.NotFound(w, r)
			return
		}
		gotSubject = r.URL.Query().Get("subject")
		page := 0
		if c := r.URL.Query().Get("cursor"); c != "" {
			page = 1
		}
		out := tangled.RepoListCollaborators_Output{}
		for _, s := range pages[page] {
			out.Items = append(out.Items, &tangled.RepoListCollaborators_ListItem{Subject: s})
		}
		if page == 0 {
			next := "page2"
			out.Cursor = &next
		}
		json.NewEncoder(w).Encode(out)
	}))
	defer srv.Close()

	cfg := &config.Config{}
	cfg.Server.Dev = true // http, not https
	jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false)
	if jcerr != nil {
		t.Fatalf("NewJetstreamClient: %v", jcerr)
	}
	tap := &Tap{
		spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg, jc: jc},
		logger:  slog.Default(),
	}

	knot := strings.TrimPrefix(srv.URL, "http://")
	tap.reconcileCollaborators(context.Background(), slog.Default(), knot, repoDid, ownerDid)

	if gotSubject != repoDid.String() {
		t.Errorf("knot queried with subject %q, want %q", gotSubject, repoDid)
	}

	for _, tc := range []struct {
		did  syntax.DID
		want bool
	}{
		{ownerDid, true},  // owner keeps access via role inheritance
		{keptDid, true},   // still on the knot
		{newDid, true},    // added from the knot's roster
		{staleDid, false}, // removed on the knot while spindle was down
	} {
		ok, err := e.IsRepoCollaborator(tc.did, repoDid)
		if err != nil {
			t.Fatalf("IsRepoCollaborator(%s): %v", tc.did, err)
		}
		if ok != tc.want {
			t.Errorf("IsRepoCollaborator(%s) = %v, want %v", tc.did, ok, tc.want)
		}
	}
}

func TestReconcileCollaboratorsKeepsGrantsOnFetchFailure(t *testing.T) {
	d, e := newTestSpindleDB(t)

	ownerDid := syntax.DID("did:plc:owner")
	repoDid := syntax.DID("did:plc:repo")
	collabDid := syntax.DID("did:plc:collab")

	if err := e.SetRepoOwner(ownerDid, repoDid); err != nil {
		t.Fatalf("SetRepoOwner: %v", err)
	}
	if err := e.AddRepoCollaborator(collabDid, repoDid); err != nil {
		t.Fatalf("AddRepoCollaborator: %v", err)
	}

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "boom", http.StatusInternalServerError)
	}))
	defer srv.Close()

	cfg := &config.Config{}
	cfg.Server.Dev = true
	tap := &Tap{
		spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg},
		logger:  slog.Default(),
	}

	tap.reconcileCollaborators(context.Background(), slog.Default(),
		strings.TrimPrefix(srv.URL, "http://"), repoDid, ownerDid)

	ok, err := e.IsRepoCollaborator(collabDid, repoDid)
	if err != nil {
		t.Fatalf("IsRepoCollaborator: %v", err)
	}
	if !ok {
		t.Error("an unreachable knot wiped the collaborator roster")
	}
}
