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 := syntax.DID("did:plc:testrepo123")

	err := e.AddSpindleMember(rbac.ThisServer, ownerDid.String())
	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(),
	}

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

	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 := 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.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)
	}

	// 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)
	}

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

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

	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")
	}
}
