package spindle

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"tangled.org/core/idresolver"
	"tangled.org/core/notifier"
	"tangled.org/core/spindle/config"
	"tangled.org/core/spindle/db"
	"tangled.org/core/spindle/models"
	"tangled.org/core/spindle/observability"
	"tangled.org/core/spindle/oidc"
	"tangled.org/core/spindle/pemkey/pemkeytest"
	"tangled.org/core/spindle/secrets"
	"tangled.org/core/workflow"
)

const (
	oidcTestIssuerURL = "https://tokens.spindle.tangled.sh"
	oidcTestAudience  = "//iam.googleapis.com/projects/tangled-prod/locations/global/workloadIdentityPools/ci/providers/spindle"
	oidcTestSha       = "1111111111111111111111111111111111111111"
	oidcTestRepoDid   = "did:plc:boltless"
	oidcTestActor     = "did:plc:pusher"
)

func oidcTestIssuer(t *testing.T) *oidc.Issuer {
	t.Helper()
	path, _ := pemkeytest.WriteP256(t)
	issuer, err := oidc.New(oidc.Options{Issuer: oidcTestIssuerURL, KeyPath: path})
	if err != nil {
		t.Fatal(err)
	}
	return issuer
}

func oidcTestPushJob(workflowYaml string) *db.JobRow {
	repoDid, actor := oidcTestRepoDid, oidcTestActor
	return &db.JobRow{
		Id:         42,
		PipelineId: models.PipelineId("3mrkp6iz6os2o"),
		RepoDid:    oidcTestRepoDid,
		Tpl: workflow.PipelineSpec{
			TriggerMetadata: &workflow.TriggerMetadata{
				Kind:  "push",
				Actor: &actor,
				Push:  &workflow.PushTriggerData{Ref: "refs/heads/main", NewSha: oidcTestSha, OldSha: strings.Repeat("0", 40)},
				Repo: &workflow.TriggerRepo{
					Knot:          "knot.test",
					Did:           "did:plc:owner",
					RepoDid:       &repoDid,
					DefaultBranch: "main",
				},
			},
			Workflows: []*workflow.WorkflowSpec{{Name: "test", Engine: "nixery", Raw: workflowYaml}},
		},
	}
}

func oidcTestSpindle(issuer *oidc.Issuer) *Spindle {
	return &Spindle{oidc: issuer, l: slog.New(slog.NewTextHandler(io.Discard, nil))}
}

func decodeJWTClaims(t *testing.T, token string) map[string]any {
	t.Helper()
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		t.Fatalf("token has %d parts, want 3", len(parts))
	}
	raw, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		t.Fatal(err)
	}
	claims := map[string]any{}
	if err := json.Unmarshal(raw, &claims); err != nil {
		t.Fatal(err)
	}
	return claims
}

func TestRunIDTokenMintsFromTheRunRecord(t *testing.T) {
	s := oidcTestSpindle(oidcTestIssuer(t))

	job := oidcTestPushJob(`on: push
env:
  TANGLED_REF: refs/heads/evil
  TANGLED_REPO_REPO_DID: did:plc:forged
  TANGLED_ID_TOKEN: smuggled
`)
	token, err := mintFrom(t, s, job, true)
	if err != nil {
		t.Fatal(err)
	}
	if token == "" {
		t.Fatal("a trusted in-repo run got no ID token")
	}

	claims := decodeJWTClaims(t, token)
	want := map[string]any{
		"iss":        oidcTestIssuerURL,
		"aud":        oidcTestAudience,
		"sub":        oidcTestRepoDid + ":refs/heads/main",
		"repository": oidcTestRepoDid,
		"ref":        "refs/heads/main",
		"actor":      oidcTestActor,
		"sha":        oidcTestSha,
	}
	for name, want := range want {
		if got := claims[name]; got != want {
			t.Errorf("%s claim = %v, want %v (claims come from the run record, not the workflow definition)", name, got, want)
		}
	}
	for _, name := range []string{"iat", "nbf", "exp", "jti"} {
		if claims[name] == nil {
			t.Errorf("%s claim is missing", name)
		}
	}
	if exp, iat := claims["exp"].(float64), claims["iat"].(float64); exp-iat != 900 {
		t.Errorf("lifetime = %v, want a run-scoped 900s", exp-iat)
	}
}

func TestRunIDTokenNeedsATrustedSource(t *testing.T) {
	s := oidcTestSpindle(oidcTestIssuer(t))
	sourceDid := "did:plc:elsewhere"

	// the shape resolveCheckout produces when a manual trigger names somebody
	// else's repository: the checkout, and every source-side env var, is the
	// other repo
	fork := oidcTestPushJob("")
	fork.SourceRepo = &workflow.TriggerRepo{RepoDid: &sourceDid, Did: sourceDid, Knot: "knot.test"}

	if token, err := mintFrom(t, s, fork, false); err != nil || token != "" {
		t.Fatalf("token = %q err = %v, want no token for an untrusted run", token, err)
	}

	// the same record on a trusted run mints, so the assertions above pin the
	// gate and not a broken fixture
	if token, err := mintFrom(t, s, fork, true); err != nil || token == "" {
		t.Fatalf("token = %q err = %v, want a token for the same record when the source is trusted", token, err)
	}
}

func TestRunIDTokenClaimsTheRunRepoNotTheSourceRepo(t *testing.T) {
	s := oidcTestSpindle(oidcTestIssuer(t))
	sourceDid := "did:plc:source"

	job := oidcTestPushJob("")
	job.Tpl.TriggerMetadata.SourceRepo = &sourceDid
	job.Tpl.TriggerMetadata.Repo.RepoDid = &sourceDid

	token, err := mintFrom(t, s, job, true)
	if err != nil {
		t.Fatal(err)
	}
	if got := decodeJWTClaims(t, token)["repository"]; got != oidcTestRepoDid {
		t.Fatalf("repository claim = %v, want the run's own repository %q", got, oidcTestRepoDid)
	}
}

func TestRunIDTokenSignsTheManualRef(t *testing.T) {
	s := oidcTestSpindle(oidcTestIssuer(t))
	actor, ref := oidcTestActor, "refs/heads/main"
	repoDid := oidcTestRepoDid
	// the shape TriggerManual records once it verified the ref's history
	// contains the sha: the ref is the server's observation, not a claim
	job := &db.JobRow{
		Id:         9,
		PipelineId: models.PipelineId("3mrkp6iz6os2o"),
		RepoDid:    oidcTestRepoDid,
		Tpl: workflow.PipelineSpec{
			TriggerMetadata: &workflow.TriggerMetadata{
				Kind:   "manual",
				Actor:  &actor,
				Repo:   &workflow.TriggerRepo{RepoDid: &repoDid, Did: "did:plc:owner", Knot: "knot.test"},
				Manual: &workflow.ManualTriggerData{Sha: oidcTestSha, Ref: &ref},
			},
		},
	}

	token, err := mintFrom(t, s, job, true)
	if err != nil {
		t.Fatal(err)
	}
	claims := decodeJWTClaims(t, token)
	if claims["ref"] != "refs/heads/main" {
		t.Fatalf("ref claim = %v, want the ref the trigger verified", claims["ref"])
	}
	if claims["sub"] != oidcTestRepoDid+":refs/heads/main" {
		t.Fatalf("sub claim = %v, want the verified repo:ref subject", claims["sub"])
	}
	if claims["sha"] != oidcTestSha {
		t.Fatalf("sha claim = %v, want the commit the run targets", claims["sha"])
	}
}

func TestRunIDTokenSkipsRunsWithoutAnIdentity(t *testing.T) {
	s := oidcTestSpindle(oidcTestIssuer(t))

	recordingless := oidcTestPushJob("")
	recordingless.Tpl.TriggerMetadata = nil
	if token, err := mintFrom(t, s, recordingless, true); err != nil || token != "" {
		t.Fatalf("token = %q err = %v, want no token for a run without a record", token, err)
	}

	commitless := oidcTestPushJob("")
	commitless.Tpl.TriggerMetadata.Push.NewSha = ""
	if token, err := mintFrom(t, s, commitless, true); err != nil || token != "" {
		t.Fatalf("token = %q err = %v, want no token for a run without a commit", token, err)
	}

	unconfigured := oidcTestSpindle(nil)
	if token, err := mintFrom(t, unconfigured, oidcTestPushJob(""), true); err != nil || token != "" {
		t.Fatalf("token = %q err = %v, want no token without a configured issuer", token, err)
	}
}

// an identity the issuer refuses to sign must leave the run alone: no token,
// no error. A push to a legal-but-long refname lands here.
func TestIDTokenMinterWithholdsAnUnsignableRun(t *testing.T) {
	s := oidcTestSpindle(oidcTestIssuer(t))

	job := oidcTestPushJob("")
	job.Tpl.TriggerMetadata.Push.Ref = "refs/heads/" + strings.Repeat("nested/", 80) + "main"

	minter := s.idTokenMinter(job, true)
	if minter == nil {
		t.Fatal("a trusted in-repo run with an unsignable ref should still try to mint")
	}
	token, err := minter(10*time.Minute, oidcTestAudience)
	if err != nil {
		t.Fatalf("err = %v, want the run to proceed without a token", err)
	}
	if token != "" {
		t.Fatalf("token = %q, want none for a ref the issuer will not sign", token)
	}
}

type recordingStep struct{}

func (recordingStep) Name() string    { return "record" }
func (recordingStep) Command() string { return "true" }
func (recordingStep) Kind() models.StepKind {
	return models.StepKindUser
}

type recordingEngine struct {
	seen map[string]string
}

func (e *recordingEngine) InitWorkflow(twf workflow.WorkflowSpec, _ workflow.PipelineSpec) (*models.Workflow, error) {
	return &models.Workflow{Name: twf.Name, Steps: []models.Step{recordingStep{}}}, nil
}

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

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

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

func (e *recordingEngine) RunStep(_ context.Context, wid models.WorkflowId, w *models.Workflow, _ int, _ []secrets.UnlockedSecret, _ models.WorkflowLogger) error {
	if e.seen == nil {
		e.seen = map[string]string{}
	}
	e.seen[wid.Name] = w.Audience
	return nil
}

func TestRunJobCarriesTheDeclaredAudienceToTheEngine(t *testing.T) {
	dbDir := t.TempDir()
	ctx := context.Background()
	d, err := db.Make(ctx, filepath.Join(dbDir, "spindle.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { d.Close() })

	logDir := t.TempDir()
	cfg := &config.Config{}
	cfg.Server.LogDir = logDir

	rec := &recordingEngine{}
	s := &Spindle{
		l:       slog.New(slog.NewTextHandler(io.Discard, nil)),
		metrics: observability.NewMetrics(),
		db:      d,
		n:       &notifier.Notifier{},
		cfg:     cfg,
		engs:    map[string]models.Engine{"record": rec},
	}

	sourceDid := "did:plc:elsewhere"
	audience := oidcTestAudience
	job := oidcTestPushJob("")
	job.SourceRepo = &workflow.TriggerRepo{RepoDid: &sourceDid, Did: sourceDid, Knot: "knot.test"}
	job.Tpl.Workflows[0].Engine = "record"
	job.Tpl.Workflows[0].Audience = &audience

	s.runJob(ctx, job)

	if got := rec.seen["test"]; got != audience {
		t.Fatalf("engine saw audience %q, want the workflow record's %q", got, audience)
	}
}

// mintFrom runs the minter a workflow would call, and reports a missing minter
// as an empty token so the trust-gate assertions read the same either way.
func mintFrom(t *testing.T, s *Spindle, job *db.JobRow, trusted bool) (string, error) {
	t.Helper()
	minter := s.idTokenMinter(job, trusted)
	if minter == nil {
		return "", nil
	}
	return minter(10*time.Minute, oidcTestAudience)
}

func TestRouterServesTheIssuerSurface(t *testing.T) {
	s := &Spindle{
		cfg:     &config.Config{Role: config.RoleMill},
		l:       slog.New(slog.NewTextHandler(io.Discard, nil)),
		metrics: observability.NewMetrics(),
		res:     idresolver.NewMockResolver(idresolver.MockDirectory{}),
		oidc:    oidcTestIssuer(t),
	}
	router := s.Router()

	fetch := func(path string) *httptest.ResponseRecorder {
		rec := httptest.NewRecorder()
		router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
		return rec
	}

	for _, path := range []string{"/token", "/jwks/", "/.well-known/other"} {
		if rec := fetch(path); rec.Code != http.StatusNotFound {
			t.Errorf("GET %s = %d, want 404: the issuer serves nothing but the discovery document and the key set", path, rec.Code)
		}
	}

	var doc struct {
		Issuer  string   `json:"issuer"`
		JWKSURI string   `json:"jwks_uri"`
		Algs    []string `json:"id_token_signing_alg_values_supported"`
	}
	rec := fetch(oidc.ConfigurationPath)
	if rec.Code != http.StatusOK {
		t.Fatalf("GET %s = %d, want 200", oidc.ConfigurationPath, rec.Code)
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
		t.Fatal(err)
	}
	if doc.Issuer != oidcTestIssuerURL || len(doc.Algs) != 1 || doc.Algs[0] != "ES256" {
		t.Errorf("discovery document = %+v, want issuer %q signing with ES256", doc, oidcTestIssuerURL)
	}

	rec = fetch(oidc.JWKSPath)
	if rec.Code != http.StatusOK {
		t.Fatalf("GET %s = %d, want 200", oidc.JWKSPath, rec.Code)
	}
	var set struct {
		Keys []struct {
			KeyID string `json:"kid"`
			Alg   string `json:"alg"`
		} `json:"keys"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &set); err != nil {
		t.Fatal(err)
	}
	if len(set.Keys) != 1 || set.Keys[0].Alg != "ES256" {
		t.Fatalf("key set = %+v, want one ES256 key", set.Keys)
	}
}
