package spindle

import (
	"context"
	"strings"
	"sync"
	"testing"
	"time"

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

	"tangled.org/core/api/org_tangled"
	"tangled.org/core/spindle/models"
	"tangled.org/core/spindle/pipeline"
	"tangled.org/core/spindle/secrets"
	"tangled.org/core/workflow"
)

const trustWorkflow = "engine: dummy\naudience: " + oidcTestAudience + "\nwhen:\n  - event: pull_request\n    branch: main\nsteps:\n  - name: hi\n    command: echo hi\n"

type runGrant struct {
	secrets       []string
	caches        int
	ref           string
	trustedSource bool
}

func (g runGrant) trusted() bool {
	return len(g.secrets) > 0 || g.caches > 0
}

func (g runGrant) has(key string) bool {
	for _, k := range g.secrets {
		if k == key {
			return true
		}
	}
	return false
}

type grantEngine struct {
	mu    sync.Mutex
	grant runGrant
	ran   bool
}

func (e *grantEngine) InitWorkflow(twf workflow.WorkflowSpec, _ workflow.PipelineSpec) (*models.Workflow, error) {
	return &models.Workflow{
		Name:   twf.Name,
		Steps:  []models.Step{recordingStep{}},
		Caches: []models.CacheEntry{{Key: "deps", Paths: []string{"node_modules"}}},
	}, nil
}

func (e *grantEngine) SetupWorkflow(context.Context, models.WorkflowId, *models.Workflow, models.WorkflowLogger) error {
	return nil
}

func (e *grantEngine) WorkflowTimeout() time.Duration { return time.Minute }

func (e *grantEngine) DestroyWorkflow(context.Context, models.WorkflowId) error { return nil }

func (e *grantEngine) RunStep(_ context.Context, _ models.WorkflowId, w *models.Workflow, _ int, unlocked []secrets.UnlockedSecret, _ models.WorkflowLogger) error {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.ran = true
	e.grant = runGrant{caches: len(w.Caches), ref: w.Environment["TANGLED_REF"], trustedSource: w.TrustedSource}
	for _, s := range unlocked {
		e.grant.secrets = append(e.grant.secrets, s.Key)
	}
	return nil
}

type trustEnv struct {
	*prPipelineEnv
	eng *grantEngine
}

func newTrustEnv(t *testing.T) *trustEnv {
	t.Helper()
	e := newPRPipelineEnv(t)
	addTestSecret(t, e.n.s, testRepoDid)
	e.n.s.oidc = oidcTestIssuer(t)
	eng := &grantEngine{}
	e.n.s.engs = map[string]models.Engine{"dummy": eng}
	return &trustEnv{prPipelineEnv: e, eng: eng}
}

func (e *trustEnv) head(t *testing.T) string {
	t.Helper()
	return e.repo.commitWorkflows(t, map[string]string{"pr.yaml": trustWorkflow})
}

// a patch whose record names where its head came from, the way submitPatch writes it
func (e *trustEnv) patchFrom(rkey, ticket, head string, editor, source syntax.DID, branch string) {
	record := e.putPatch(testRepoDid, rkey, ticket, head, editor)
	record["source"] = map[string]any{"repo": source.String(), "branch": branch}
	e.knot.putVersion(testRepoDid, org_tangled.ReviewPatchNSID, rkey, editor, record)
	e.n.consumed(e.emitPatch(testRepoDid, rkey, opCreate))
}

// runs the pipeline's queued job the way a job worker would
func (e *trustEnv) run(t *testing.T, id models.PipelineId) runGrant {
	t.Helper()
	ctx := context.Background()
	for {
		job, err := e.n.s.db.DequeueJob(ctx, nil)
		if err != nil {
			t.Fatal(err)
		}
		if job == nil {
			t.Fatalf("pipeline %s has no queued job", id)
		}
		if job.PipelineId != id {
			continue
		}
		e.eng.mu.Lock()
		e.eng.ran, e.eng.grant = false, runGrant{}
		e.eng.mu.Unlock()
		e.n.s.runJob(ctx, job)
		e.eng.mu.Lock()
		defer e.eng.mu.Unlock()
		if !e.eng.ran {
			t.Fatalf("pipeline %s never ran a step", id)
		}
		return e.eng.grant
	}
}

func (e *trustEnv) approve(t *testing.T, id models.PipelineId) {
	t.Helper()
	if err := e.n.s.ApprovePipeline(context.Background(), testRepoDid, id); err != nil {
		t.Fatal(err)
	}
}

func (e *trustEnv) retry(t *testing.T, id models.PipelineId) models.PipelineId {
	t.Helper()
	retried, err := e.n.s.RetryPipeline(context.Background(), testOwner, testRepoDid, id, nil)
	if err != nil {
		t.Fatal(err)
	}
	return retried
}

func wantUntrusted(t *testing.T, what string, got runGrant) {
	t.Helper()
	if got.trusted() || got.trustedSource || got.has(models.IDTokenEnvVar) {
		t.Errorf("%s ran with secrets %v, %d caches and TrustedSource %t, want none: its code isn't the repo's", what, got.secrets, got.caches, got.trustedSource)
	}
}

func wantTrusted(t *testing.T, what string, got runGrant) {
	t.Helper()
	if !got.has("api_key") || !got.has(models.IDTokenEnvVar) || got.caches == 0 || !got.trustedSource {
		t.Errorf("%s ran with secrets %v, %d caches and TrustedSource %t, want the repo secret, an id token, its caches and TrustedSource", what, got.secrets, got.caches, got.trustedSource)
	}
}

func TestForkPullRunsUntrusted(t *testing.T) {
	e := newTrustEnv(t)
	e.ticketAs(otherPull, "open", testDave, opCreate)
	e.patchFrom("bafydaveone", otherPull, e.head(t), testDave, faultOtherRepoDid, "feature")
	first := expectPRPipelines(t, e.n, 1)[0]
	wantBlocked(t, e.n, first, true)

	e.approve(t, first.ID)
	wantUntrusted(t, "an approved fork pull", e.run(t, first.ID))
	wantUntrusted(t, "the retry of an approved fork pull", e.run(t, e.retry(t, first.ID)))

	e.patchFrom("bafydavetwo", otherPull, e.head(t), testDave, faultOtherRepoDid, "feature")
	later := expectPRPipelines(t, e.n, 3)[2]
	wantBlocked(t, e.n, later, true)
	e.approve(t, later.ID)
	wantUntrusted(t, "a later round of an approved fork pull", e.run(t, later.ID))

	// a collaborator's fork needs no approval, and still isn't the repo's code
	e.ticketAs(pullRkey, "open", testBob, opCreate)
	e.patchFrom(patchOne, pullRkey, e.head(t), testBob, faultOtherRepoDid, "feature")
	own := expectPRPipelines(t, e.n, 4)[3]
	wantBlocked(t, e.n, own, false)
	wantUntrusted(t, "a collaborator's fork pull", e.run(t, own.ID))
}

func TestSameRepoBranchPullRunsTrusted(t *testing.T) {
	e := newTrustEnv(t)
	e.ticketAs(pullRkey, "open", testBob, opCreate)
	e.patchFrom(patchOne, pullRkey, e.head(t), testBob, testRepoDid, "feature")
	got := expectPRPipelines(t, e.n, 1)[0]
	wantBlocked(t, e.n, got, false)

	wantTrusted(t, "a collaborator's branch of the repo", e.run(t, got.ID))
	wantTrusted(t, "the retry of a collaborator's branch", e.run(t, e.retry(t, got.ID)))
}

// a patch record says what the client claims. nothing checks the branch against the head
func TestClaimedBranchOfANonCollaboratorRunsUntrusted(t *testing.T) {
	e := newTrustEnv(t)
	e.ticketAs(otherPull, "open", testDave, opCreate)
	e.patchFrom("bafydaveone", otherPull, e.head(t), testDave, testRepoDid, "main")
	got := expectPRPipelines(t, e.n, 1)[0]
	wantBlocked(t, e.n, got, true)

	e.approve(t, got.ID)
	run := e.run(t, got.ID)
	wantUntrusted(t, "an approved pull claiming main", run)
	if run.ref == "refs/heads/main" {
		t.Errorf("an approved pull claiming main ran as %s, want it scoped to the pull", run.ref)
	}
	wantUntrusted(t, "its retry", e.run(t, e.retry(t, got.ID)))
}

func (e *trustEnv) enqueue(t *testing.T, id models.PipelineId, trigger workflow.TriggerMetadata, source *workflow.TriggerRepo, blocked bool) models.PipelineId {
	t.Helper()
	audience := oidcTestAudience
	tpl := workflow.PipelineSpec{
		TriggerMetadata: &trigger,
		Workflows:       []*workflow.WorkflowSpec{{Name: "pr.yaml", Engine: "dummy", Raw: trustWorkflow, Audience: &audience}},
	}
	record, err := pipeline.FromTangled(id, time.Now(), tpl)
	if err != nil {
		t.Fatal(err)
	}
	if err := e.n.s.processPipeline(context.Background(), testRepoDid, tpl, record, source, blocked); err != nil {
		t.Fatal(err)
	}
	return id
}

func (e *trustEnv) triggerRepo() *workflow.TriggerRepo {
	repoDid := testRepoDid.String()
	return &workflow.TriggerRepo{Did: testOwner.String(), Knot: "knot.test", RepoDid: &repoDid}
}

// a stored record still carries whatever branch the patch claimed
func TestStoredPullRunsStayUntrusted(t *testing.T) {
	e := newTrustEnv(t)
	dave, action := testDave.String(), "opened"
	pull := "at://" + testRepoDid.String() + "/" + org_tangled.TrackTicketNSID + "/" + otherPull
	stored := func(id models.PipelineId, branch string, blocked bool) models.PipelineId {
		return e.enqueue(t, id, workflow.TriggerMetadata{
			Kind:  string(workflow.TriggerKindPullRequest),
			Actor: &dave,
			Repo:  e.triggerRepo(),
			PullRequest: &workflow.PullRequestTriggerData{
				Action: &action, Pull: &pull, SourceBranch: branch, SourceSha: e.head(t), TargetBranch: "main",
			},
		}, e.triggerRepo(), blocked)
	}

	claimed := stored("3mstoredclaim", "main", true)
	e.approve(t, claimed)
	wantUntrusted(t, "a stored pull claiming main, approved", e.run(t, claimed))
	wantUntrusted(t, "its retry", e.run(t, e.retry(t, claimed)))

	fork := stored("3mstoredfork2", "", false)
	wantUntrusted(t, "a stored fork pull", e.run(t, fork))
	wantUntrusted(t, "its retry", e.run(t, e.retry(t, fork)))
}

func TestPushAndManualRunsKeepTheirTrust(t *testing.T) {
	e := newTrustEnv(t)
	owner, sha, ref := testOwner.String(), e.head(t), "refs/heads/main"

	push := e.enqueue(t, "3mtrustpush22", workflow.TriggerMetadata{
		Kind:  string(workflow.TriggerKindPush),
		Actor: &owner,
		Repo:  e.triggerRepo(),
		Push:  &workflow.PushTriggerData{Ref: ref, NewSha: sha, OldSha: strings.Repeat("0", 40)},
	}, e.triggerRepo(), false)
	wantTrusted(t, "a push", e.run(t, push))

	fork := faultOtherRepoDid.String()
	manual := e.enqueue(t, "3mtrustmanual", workflow.TriggerMetadata{
		Kind:       string(workflow.TriggerKindManual),
		Actor:      &owner,
		Repo:       e.triggerRepo(),
		Manual:     &workflow.ManualTriggerData{Sha: sha, Ref: &ref},
		SourceRepo: &fork,
	}, &workflow.TriggerRepo{RepoDid: &fork, Did: fork, Knot: "knot.test"}, false)
	wantUntrusted(t, "a manual run of a fork", e.run(t, manual))
}
