package spindle

import (
	"context"
	"encoding/json"
	"log/slog"
	"strings"
	"tangled.org/core/jetstream"
	"testing"
	"time"

	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
	"tangled.org/core/api/tangled"
	"tangled.org/core/eventconsumer"
	"tangled.org/core/idresolver"
	"tangled.org/core/rbac"
	"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 TestProcessRepo_MembershipCheck(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(),
	}

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

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

	err := e.AddSpindle(rbac.ThisServer)
	if err != nil {
		t.Fatalf("AddSpindle: %v", err)
	}
	err = e.AddSpindleMember(rbac.ThisServer, ownerDid.String())
	if err != nil {
		t.Fatalf("AddSpindleMember: %v", err)
	}

	recNonMember := tangled.Repo{
		Knot:      "knot.test",
		RepoDid:   &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.GetRepoByOwnerRkey(nonMemberDid, "test-repo-rkey")
	if err == nil {
		t.Fatal("repo for non-member was registered in DB, expected rejection")
	}

	recMember := tangled.Repo{
		Knot:      "knot.test",
		RepoDid:   &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_PushAllowedCheck(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(),
	}

	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.AddRepo(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.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String())
	if err != nil {
		t.Fatalf("AddRepo permissions: %v", err)
	}
	err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String())
	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(),
	}

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

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

	err := e.AddSpindle(rbac.ThisServer)
	if err != nil {
		t.Fatalf("AddSpindle: %v", err)
	}
	err = e.AddSpindleMember(rbac.ThisServer, aliceDid.String())
	if err != nil {
		t.Fatalf("AddSpindleMember alice: %v", err)
	}
	err = e.AddSpindleMember(rbac.ThisServer, bobDid.String())
	if err != nil {
		t.Fatalf("AddSpindleMember bob: %v", err)
	}

	err = d.AddRepo(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)
	}

	// bob tries to register alice's repo did, must reject the hijack
	recBob := tangled.Repo{
		Knot:      "knot.test",
		RepoDid:   &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)
	}

	_, err = d.GetRepoByOwnerRkey(bobDid, "bob-repo")
	if err == nil {
		t.Fatal("bob successfully hijacked alice's repoDid in DB, expected rejection")
	}
}

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

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

	ownerDid := syntax.DID("did:plc:repoowner")
	otherDid := syntax.DID("did:plc:otheractor")
	subjectDid := syntax.DID("did:plc:collabsubject")
	repoDid := syntax.DID("did:plc:testrepo123")

	h, err := syntax.ParseHandle("collabsubject.test")
	if err != nil {
		t.Fatalf("syntax.ParseHandle: %v", err)
	}
	mockIdent := &identity.Identity{
		DID:    subjectDid,
		Handle: h,
	}
	resolver := idresolver.NewMockResolver(&mockDirectory{ident: mockIdent})

	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:     resolver,
		jc:      jc,
		rootCtx: context.Background(),
	}

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

	err = d.AddRepo(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: %v", err)
	}

	collabRecord := tangled.RepoCollaborator{
		Subject: subjectDid.String(),
		Repo:    repoDid.String(),
	}
	collabRecordJson, _ := json.Marshal(collabRecord)

	err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        otherDid,
		Rkey:       "collab-rkey-1",
		Collection: syntax.NSID(tangled.RepoCollaboratorNSID),
		Action:     tapc.RecordCreateAction,
		Record:     collabRecordJson,
	})
	if err != nil {
		t.Fatalf("processCollaborator returned error: %v", err)
	}

	_, err = d.GetRepoCollaborator(otherDid, "collab-rkey-1")
	if err == nil {
		t.Fatal("collaborator from non-owner was registered in DB")
	}

	err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        ownerDid,
		Rkey:       "collab-rkey-2",
		Collection: syntax.NSID(tangled.RepoCollaboratorNSID),
		Action:     tapc.RecordCreateAction,
		Record:     collabRecordJson,
	})
	if err != nil {
		t.Fatalf("processCollaborator returned error: %v", err)
	}
	_, err = d.GetRepoCollaborator(ownerDid, "collab-rkey-2")
	if err == nil {
		t.Fatal("collaborator registered despite missing Casbin invite permission")
	}

	err = e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String())
	if err != nil {
		t.Fatalf("AddRepo permissions: %v", err)
	}

	err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        ownerDid,
		Rkey:       "collab-rkey-3",
		Collection: syntax.NSID(tangled.RepoCollaboratorNSID),
		Action:     tapc.RecordCreateAction,
		Record:     collabRecordJson,
	})
	if err != nil {
		t.Fatalf("processCollaborator failed for authorized owner: %v", err)
	}

	c, err := d.GetRepoCollaborator(ownerDid, "collab-rkey-3")
	if err != nil {
		t.Fatalf("GetRepoCollaborator error: %v", err)
	}
	if c.Subject != subjectDid || c.RepoDid != repoDid {
		t.Fatalf("unexpected collaborator: %+v", c)
	}

	ok, err := e.IsRepoCollaborator(subjectDid.String(), rbac.ThisServer, repoDid.String())
	if err != nil || !ok {
		t.Fatalf("Casbin policy for collaborator missing or err: %v", err)
	}

	err = tap.processCollaborator(context.Background(), &tapc.RecordEventData{
		Live:       true,
		Did:        ownerDid,
		Rkey:       "collab-rkey-3",
		Collection: syntax.NSID(tangled.RepoCollaboratorNSID),
		Action:     tapc.RecordDeleteAction,
	})
	if err != nil {
		t.Fatalf("delete collaborator process returned error: %v", err)
	}

	_, err = d.GetRepoCollaborator(ownerDid, "collab-rkey-3")
	if err == nil {
		t.Fatal("collaborator DB row remained after deletion")
	}

	ok, err = e.IsRepoCollaborator(subjectDid.String(), rbac.ThisServer, repoDid.String())
	if err != nil || ok {
		t.Fatal("Casbin policy for collaborator remained after deletion")
	}
}

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(),
	}

	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.AddRepo(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.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String())
	if err != nil {
		t.Fatalf("AddRepo policy: %v", err)
	}

	err = d.AddRepoCollaborator(db.RepoCollaborator{
		OwnerDid: ownerDid,
		Rkey:     "collab-rkey",
		Subject:  collabDid,
		RepoDid:  repoDid,
	})
	if err != nil {
		t.Fatalf("AddCollaborator DB: %v", err)
	}

	err = e.AddCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String())
	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.GetRepoByOwnerRkey(ownerDid, "test-repo-rkey")
	if err == nil {
		t.Fatal("repo remained in DB after delete")
	}

	collabs, err := d.ListCollaboratorsByRepoDid(repoDid)
	if err != nil {
		t.Fatalf("ListCollaboratorsByRepoDid: %v", err)
	}
	if len(collabs) > 0 {
		t.Fatal("collaborators remained in DB after delete")
	}

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

	ok, err = e.IsRepoCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String())
	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(),
	}

	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.AddRepo(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.AddRepo(aliceDid.String(), rbac.ThisServer, repoDid.String())
	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.GetRepoByOwnerRkey(aliceDid, "test-repo-rkey")
	if err != nil {
		t.Fatalf("Alice's repo was deleted or error: %v", err)
	}

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

func TestProcessCollaborator_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,
		res:     idresolver.DefaultResolver("https://plc.test"),
		jc:      jc,
		rootCtx: context.Background(),
	}

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

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

	err := d.AddRepo(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: %v", err)
	}

	err = e.AddRepo(ownerDid.String(), rbac.ThisServer, repoDid.String())
	if err != nil {
		t.Fatalf("AddRepo permissions: %v", err)
	}

	err = d.AddRepoCollaborator(db.RepoCollaborator{
		OwnerDid: ownerDid,
		Rkey:     "collab-rkey",
		Subject:  collabDid,
		RepoDid:  repoDid,
	})
	if err != nil {
		t.Fatalf("AddRepoCollaborator: %v", err)
	}

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

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

	_, err = d.GetRepoCollaborator(ownerDid, "collab-rkey")
	if err != nil {
		t.Fatalf("collaborator was deleted from DB: %v", err)
	}

	ok, err := e.IsRepoCollaborator(collabDid.String(), rbac.ThisServer, repoDid.String())
	if err != nil || !ok {
		t.Fatal("collaborator policy was removed from Casbin by forged delete")
	}
}
