package service

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/pingcap/agent-git-service/internal/crypto"
	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/embedding"
	"github.com/pingcap/agent-git-service/internal/gitstore"
	applog "github.com/pingcap/agent-git-service/internal/logging"
	"github.com/pingcap/agent-git-service/internal/tenant"
	"github.com/pingcap/agent-git-service/internal/wikicatalog"

	"gorm.io/gorm"
)

type testTenantResourcesProvider map[tenant.ID]*TenantResources

func (p testTenantResourcesProvider) ForTenant(id tenant.ID) (*TenantResources, error) {
	resources, ok := p[id]
	if !ok {
		return nil, fmt.Errorf("unknown tenant %q", id)
	}
	return resources, nil
}

func testTenantInfo(id tenant.ID) tenant.Info {
	return tenant.Info{
		ID:               id,
		CanonicalBaseURL: "https://" + string(id) + ".example.test",
	}
}

func testTenantResources(t *testing.T, id tenant.ID, database *gorm.DB) *TenantResources {
	t.Helper()
	store, err := gitstore.New(t.TempDir())
	if err != nil {
		t.Fatalf("gitstore.New: %v", err)
	}
	blob := wikicatalog.NewBlobStore(t.TempDir())
	box, err := crypto.NewSecretBox("")
	if err != nil {
		t.Fatalf("crypto.NewSecretBox: %v", err)
	}
	return &TenantResources{
		Info:        testTenantInfo(id),
		DB:          database,
		Git:         store,
		WikiCatalog: wikicatalog.New(database, blob),
		WikiBlob:    blob,
		SecretBox:   box,
		Embedder:    embedding.NopEmbedder{},
	}
}

func TestNewServiceRequiresTenantProvider(t *testing.T) {
	if _, err := NewService(t.Context(), nil); !errors.Is(err, ErrTenantResourcesUnavailable) {
		t.Fatalf("NewService error = %v, want %v", err, ErrTenantResourcesUnavailable)
	}
}

func mustNewTenantService(t *testing.T, provider TenantResourcesProvider) *Service {
	t.Helper()
	if resourcesByID, ok := provider.(testTenantResourcesProvider); ok {
		for id, resources := range resourcesByID {
			if resources == nil || resources.Info.ID != id {
				t.Fatalf("tenant resource identity mismatch: key=%q resources=%v", id, resources)
			}
			if err := resources.Validate(); err != nil {
				t.Fatalf("validate tenant %q resources: %v", id, err)
			}
		}
	}
	svc, err := NewService(t.Context(), provider)
	if err != nil {
		t.Fatalf("NewService: %v", err)
	}
	return svc
}

func TestScopeRejectsCallerLabeledDatabase(t *testing.T) {
	id := tenant.ID("tenant-a")
	resources := testTenantResources(t, id, failClosedDB)
	svc := mustNewTenantService(t, testTenantResourcesProvider{id: resources})
	ctx := tenant.WithInfo(context.Background(), resources.Info)

	forged := context.WithValue(ctx, dbContextKey{}, dbContextBinding{
		DB:       &gorm.DB{},
		TenantID: id,
	})
	if _, err := svc.Scope(forged); !errors.Is(err, ErrTenantDBScopeMismatch) {
		t.Fatalf("Scope error = %v, want %v", err, ErrTenantDBScopeMismatch)
	}
}

func TestScopeRequiresKnownTenantContext(t *testing.T) {
	id := tenant.ID("tenant-a")
	resources := testTenantResources(t, id, &gorm.DB{})
	svc := mustNewTenantService(t, testTenantResourcesProvider{id: resources})

	if _, err := svc.Scope(context.Background()); !errors.Is(err, tenant.ErrRequired) {
		t.Fatalf("Scope without tenant error = %v, want %v", err, tenant.ErrRequired)
	}

	unknown := tenant.WithInfo(context.Background(), testTenantInfo("tenant-b"))
	if _, err := svc.Scope(unknown); !errors.Is(err, ErrTenantResourcesUnavailable) {
		t.Fatalf("Scope with unknown tenant error = %v, want %v", err, ErrTenantResourcesUnavailable)
	}
}

func TestDBForCtxFailsClosedWithoutPanickingWhenTenantIsMissing(t *testing.T) {
	id := tenant.ID("tenant-a")
	resources := testTenantResources(t, id, &gorm.DB{})
	svc := mustNewTenantService(t, testTenantResourcesProvider{id: resources})

	var users []db.User
	result := svc.DBForCtx(context.Background()).
		Where("login = ?", "must-not-run").
		Find(&users)
	if !errors.Is(result.Error, tenant.ErrRequired) {
		t.Fatalf("DBForCtx query error = %v, want %v", result.Error, tenant.ErrRequired)
	}
	if len(users) != 0 {
		t.Fatalf("DBForCtx query returned users without a tenant: %#v", users)
	}
}

func TestScopeRejectsForgedPolicyForKnownTenantID(t *testing.T) {
	id := tenant.ID("tenant-a")
	resources := testTenantResources(t, id, &gorm.DB{})
	svc := mustNewTenantService(t, testTenantResourcesProvider{id: resources})
	forgedInfo := resources.Info.Clone()
	forgedInfo.CanonicalBaseURL = "https://attacker.example.test"
	forged := tenant.WithInfo(context.Background(), forgedInfo)

	if _, err := svc.Scope(forged); !errors.Is(err, ErrTenantResourceMismatch) {
		t.Fatalf("Scope error = %v, want %v", err, ErrTenantResourceMismatch)
	}
}

func TestTenantResourcesRejectMismatchedWikiCatalogOwnership(t *testing.T) {
	resources := testTenantResources(t, "tenant-a", failClosedDB)
	resources.WikiCatalog = wikicatalog.New(&gorm.DB{}, resources.WikiBlob)

	if err := resources.Validate(); !errors.Is(err, ErrTenantResourceMismatch) {
		t.Fatalf("Validate error = %v, want %v", err, ErrTenantResourceMismatch)
	}
}

func TestDBForTenantResourcesRejectsDifferentResourceBundle(t *testing.T) {
	resourcesA := testTenantResources(t, "tenant-a", failClosedDB)
	resourcesB := testTenantResources(t, "tenant-b", failClosedDB)
	svc := mustNewTenantService(t, testTenantResourcesProvider{
		resourcesA.Info.ID: resourcesA,
		resourcesB.Info.ID: resourcesB,
	})
	ctxA := tenant.WithInfo(context.Background(), resourcesA.Info)

	resolved := svc.DBForTenantResources(ctxA, resourcesB)
	if !errors.Is(resolved.Error, ErrTenantResourceMismatch) {
		t.Fatalf("cross-tenant resource database error = %v, want %v", resolved.Error, ErrTenantResourceMismatch)
	}
}

func TestValidateTenantProvenanceRejectsMissingOrDifferentTenant(t *testing.T) {
	id := tenant.ID("tenant-a")
	database, cleanup := openMigratedServiceTestDB(t)
	defer cleanup()
	resources := testTenantResources(t, id, database)
	svc := mustNewTenantService(t, testTenantResourcesProvider{id: resources})
	ctx := tenant.WithInfo(context.Background(), resources.Info)

	if err := svc.validateTenantProvenance(ctx, "tenant-a"); err != nil {
		t.Fatalf("matching provenance: %v", err)
	}
	for _, stored := range []string{"", "tenant-b"} {
		if err := svc.validateTenantProvenance(ctx, stored); !errors.Is(err, db.ErrTenantProvenanceMismatch) {
			t.Fatalf("stored tenant %q error = %v, want %v", stored, err, db.ErrTenantProvenanceMismatch)
		}
	}
}

func TestWikiReferenceQueueRejectsMismatchedTenantEnvelope(t *testing.T) {
	resources := testTenantResources(t, "tenant-a", failClosedDB)
	svc := mustNewTenantService(t, testTenantResourcesProvider{resources.Info.ID: resources})
	ctx := tenant.WithInfo(context.Background(), resources.Info)
	run := false

	_, err := svc.enqueueWikiReferenceJob(wikiReferenceJob{
		ctx:      ctx,
		tenantID: "tenant-b",
		repo:     "owner/repo",
		run: func(context.Context) error {
			run = true
			return nil
		},
	})
	if !errors.Is(err, db.ErrTenantProvenanceMismatch) {
		t.Fatalf("enqueue mismatched wiki reference job error = %v, want %v", err, db.ErrTenantProvenanceMismatch)
	}
	if run {
		t.Fatal("mismatched wiki reference job ran")
	}
}

func TestMismatchedWorkflowEnvelopeFailsInIssuingTenantOnly(t *testing.T) {
	databaseA, cleanupA := openMigratedServiceTestDB(t)
	defer cleanupA()
	databaseB, cleanupB := openMigratedServiceTestDB(t)
	defer cleanupB()

	resourcesA := testTenantResources(t, "tenant-a", databaseA)
	resourcesB := testTenantResources(t, "tenant-b", databaseB)
	svc := mustNewTenantService(t, testTenantResourcesProvider{
		"tenant-a": resourcesA,
		"tenant-b": resourcesB,
	})
	run := db.WorkflowRun{
		TenantProvenance: db.TenantProvenance{TenantID: "tenant-a"},
		Status:           db.RunQueued,
		Conclusion:       "",
		ExecutionLeaseID: "tenant-a-lease",
	}
	if err := databaseA.Create(&run).Error; err != nil {
		t.Fatalf("create tenant-a workflow run: %v", err)
	}

	ctxB := tenant.WithInfo(context.Background(), resourcesB.Info)
	svc.executeWorkflow(ctxB, run, db.Repository{}, db.WorkflowRunSnapshot{}, run.ExecutionLeaseID)

	var stored db.WorkflowRun
	if err := databaseA.First(&stored, run.ID).Error; err != nil {
		t.Fatalf("load tenant-a workflow run: %v", err)
	}
	if stored.Status != db.RunCompleted || stored.Conclusion != db.ConclusionFailure {
		t.Fatalf("tenant-a workflow terminal state = status %q conclusion %q", stored.Status, stored.Conclusion)
	}
	var tenantBRows int64
	if err := databaseB.Model(&db.WorkflowRun{}).Where("id = ?", run.ID).Count(&tenantBRows).Error; err != nil {
		t.Fatalf("count tenant-b workflow runs: %v", err)
	}
	if tenantBRows != 0 {
		t.Fatalf("mismatched workflow touched %d tenant-b rows", tenantBRows)
	}
}

func TestMismatchedWebhookEnvelopeFailsInIssuingTenantOnly(t *testing.T) {
	databaseA, cleanupA := openMigratedServiceTestDB(t)
	defer cleanupA()
	databaseB, cleanupB := openMigratedServiceTestDB(t)
	defer cleanupB()

	resourcesA := testTenantResources(t, "tenant-a", databaseA)
	resourcesB := testTenantResources(t, "tenant-b", databaseB)
	svc := mustNewTenantService(t, testTenantResourcesProvider{
		"tenant-a": resourcesA,
		"tenant-b": resourcesB,
	})
	owner := db.User{Login: "tenant-a-owner", Type: db.TypeUser}
	if err := databaseA.Create(&owner).Error; err != nil {
		t.Fatalf("create tenant-a webhook owner: %v", err)
	}
	repo := db.Repository{
		OwnerID:       owner.ID,
		Name:          "repo",
		FullName:      "tenant-a-owner/repo",
		DefaultBranch: "main",
	}
	if err := databaseA.Create(&repo).Error; err != nil {
		t.Fatalf("create tenant-a webhook repo: %v", err)
	}
	hook := db.Webhook{
		RepositoryID: repo.ID,
		Name:         "web",
		Active:       true,
	}
	if err := databaseA.Create(&hook).Error; err != nil {
		t.Fatalf("create tenant-a webhook: %v", err)
	}
	delivery := db.HookDelivery{
		TenantProvenance: db.TenantProvenance{TenantID: "tenant-a"},
		WebhookID:        hook.ID,
		RepositoryID:     repo.ID,
		GUID:             "tenant-a-delivery",
		Event:            "push",
		Status:           webhookStatusPending,
	}
	if err := databaseA.Create(&delivery).Error; err != nil {
		t.Fatalf("create tenant-a webhook delivery: %v", err)
	}

	ctxB := tenant.WithInfo(context.Background(), resourcesB.Info)
	svc.processWebhookJob(ctxB, webhookJob{
		ctx:        ctxB,
		tenantID:   "tenant-a",
		deliveryID: delivery.ID,
	})

	var stored db.HookDelivery
	if err := databaseA.First(&stored, delivery.ID).Error; err != nil {
		t.Fatalf("load tenant-a webhook delivery: %v", err)
	}
	if stored.Status != webhookStatusError || stored.LastError == "" {
		t.Fatalf("tenant-a webhook terminal state = status %q error %q", stored.Status, stored.LastError)
	}
	var tenantBRows int64
	if err := databaseB.Model(&db.HookDelivery{}).Where("id = ?", delivery.ID).Count(&tenantBRows).Error; err != nil {
		t.Fatalf("count tenant-b webhook deliveries: %v", err)
	}
	if tenantBRows != 0 {
		t.Fatalf("mismatched webhook touched %d tenant-b rows", tenantBRows)
	}

	forgedInfo := resourcesA.Info.Clone()
	forgedInfo.CanonicalBaseURL = "https://forged.example.test"
	forgedCtx := tenant.WithInfo(context.Background(), forgedInfo)
	requestReceived := false
	target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		requestReceived = true
		w.WriteHeader(http.StatusNoContent)
	}))
	defer target.Close()

	forgedDelivery := db.HookDelivery{
		TenantProvenance: db.TenantProvenance{TenantID: "tenant-a"},
		WebhookID:        hook.ID,
		RepositoryID:     repo.ID,
		GUID:             "tenant-a-forged-context",
		Event:            "push",
		Status:           webhookStatusPending,
	}
	if err := databaseA.Create(&forgedDelivery).Error; err != nil {
		t.Fatalf("create forged-context delivery: %v", err)
	}
	svc.processWebhookJob(forgedCtx, webhookJob{
		ctx:        forgedCtx,
		tenantID:   "tenant-a",
		deliveryID: forgedDelivery.ID,
		url:        target.URL,
	})
	if requestReceived {
		t.Fatal("webhook request was sent with a forged tenant policy")
	}
	if err := databaseA.First(&forgedDelivery, forgedDelivery.ID).Error; err != nil {
		t.Fatalf("reload forged-context delivery: %v", err)
	}
	if forgedDelivery.Status != webhookStatusError || forgedDelivery.LastError == "" {
		t.Fatalf("forged-context delivery terminal state = status %q error %q", forgedDelivery.Status, forgedDelivery.LastError)
	}
}

func TestContextWithPrincipalRejectsDifferentTenant(t *testing.T) {
	ctxA := tenant.WithInfo(context.Background(), testTenantInfo("tenant-a"))
	principalB := Principal{
		tenantID: "tenant-b",
		user:     db.User{ID: 1, Login: "same-login"},
	}

	if _, err := ContextWithPrincipal(ctxA, principalB); !errors.Is(err, ErrPrincipalTenantMismatch) {
		t.Fatalf("ContextWithPrincipal error = %v, want %v", err, ErrPrincipalTenantMismatch)
	}
}

func TestContextWithPrincipalRequiresTenantBoundIdentity(t *testing.T) {
	if _, err := ContextWithPrincipal(context.Background(), Principal{
		tenantID: "tenant-a",
		user:     db.User{ID: 1, Login: "tenant-user"},
	}); !errors.Is(err, ErrPrincipalTenantMismatch) {
		t.Fatalf("tenantless ContextWithPrincipal error = %v, want %v", err, ErrPrincipalTenantMismatch)
	}

	ctx := tenant.WithInfo(context.Background(), testTenantInfo("tenant-a"))
	if _, err := ContextWithPrincipal(ctx, Principal{
		user: db.User{ID: 1, Login: "unscoped-user"},
	}); !errors.Is(err, ErrPrincipalTenantMismatch) {
		t.Fatalf("unscoped ContextWithPrincipal error = %v, want %v", err, ErrPrincipalTenantMismatch)
	}
}

func TestPrincipalFromContextRejectsTenantlessPrincipal(t *testing.T) {
	ctx := context.WithValue(context.Background(), ctxKeyUser, Principal{
		tenantID: "tenant-a",
		user:     db.User{ID: 1, Login: "tenant-user"},
	})
	if principal, ok := PrincipalFromContext(ctx); ok {
		t.Fatalf("tenantless principal = %#v, want rejected", principal)
	}
}

func TestRepoCacheRejectsReboundTenantContext(t *testing.T) {
	ctx := tenant.WithInfo(context.Background(), testTenantInfo("tenant-a"))
	ctx, err := ContextWithRepoCache(ctx)
	if err != nil {
		t.Fatalf("ContextWithRepoCache: %v", err)
	}
	repoCacheSet(ctx, db.Repository{ID: 1, FullName: "same/repo"})
	repoPermissionCacheSet(ctx, 1, RepoPermissionAdmin)

	rebound := tenant.WithInfo(ctx, testTenantInfo("tenant-b"))
	if _, err := tenant.Require(rebound); !errors.Is(err, tenant.ErrRebinding) {
		t.Fatalf("rebound tenant error = %v, want %v", err, tenant.ErrRebinding)
	}
	if repo, ok := repoCacheGet(rebound, "same/repo"); ok {
		t.Fatalf("rebound context read cached repository %#v", repo)
	}
	if permission, ok := repoPermissionCacheGet(rebound, 1); ok {
		t.Fatalf("rebound context read cached permission %q", permission)
	}
}

func TestContextWithRepoCacheRequiresTenant(t *testing.T) {
	if _, err := ContextWithRepoCache(context.Background()); !errors.Is(err, tenant.ErrRequired) {
		t.Fatalf("ContextWithRepoCache error = %v, want %v", err, tenant.ErrRequired)
	}
}

func TestDetachContextPreservesTenantPastRequestCancellation(t *testing.T) {
	id := tenant.ID("tenant-a")
	resources := testTenantResources(t, id, failClosedDB)
	serverCtx, cancelServer := context.WithCancel(context.Background())
	defer cancelServer()
	svc, err := NewService(serverCtx, testTenantResourcesProvider{id: resources})
	if err != nil {
		t.Fatalf("NewService: %v", err)
	}

	requestCtx, cancelRequest := context.WithCancel(tenant.WithInfo(context.Background(), resources.Info))
	detached, err := svc.DetachContext(requestCtx)
	if err != nil {
		t.Fatalf("DetachContext: %v", err)
	}
	cancelRequest()

	if err := detached.Err(); err != nil {
		t.Fatalf("detached context inherited request cancellation: %v", err)
	}
	info, err := tenant.Require(detached)
	if err != nil {
		t.Fatalf("detached tenant: %v", err)
	}
	if !info.Equal(resources.Info) {
		t.Fatalf("detached tenant = %#v, want %#v", info, resources.Info)
	}
	var loggedTenant string
	for _, attr := range applog.SnapshotAttrs(detached) {
		if attr.Key == "tenant_id" {
			loggedTenant = attr.Value.String()
		}
	}
	if loggedTenant != string(id) {
		t.Fatalf("detached log tenant = %q, want %q", loggedTenant, id)
	}
}

func TestDetachContextPreservesTenantBeforeTenantBoundPrincipal(t *testing.T) {
	resources := testTenantResources(t, "tenant-a", failClosedDB)
	info := resources.Info
	svc := mustNewTenantService(t, testTenantResourcesProvider{info.ID: resources})
	requestCtx := tenant.WithInfo(context.Background(), info)
	requestCtx, err := ContextWithPrincipal(requestCtx, Principal{
		tenantID: info.ID,
		user:     db.User{ID: 1, Login: "tenant-user"},
	})
	if err != nil {
		t.Fatalf("ContextWithPrincipal: %v", err)
	}

	detached, err := svc.DetachContext(requestCtx)
	if err != nil {
		t.Fatalf("DetachContext: %v", err)
	}
	detachedInfo, err := tenant.Require(detached)
	if err != nil {
		t.Fatalf("detached tenant: %v", err)
	}
	if !detachedInfo.Equal(info) {
		t.Fatalf("detached tenant = %#v, want %#v", detachedInfo, info)
	}
	principal, ok := PrincipalFromContext(detached)
	if !ok {
		t.Fatal("detached context lost principal")
	}
	if principal.TenantID() != info.ID || principal.User().Login != "tenant-user" {
		t.Fatalf("detached principal = %#v, want tenant %q user %q", principal, info.ID, "tenant-user")
	}
}

func TestDetachContextRequiresRegisteredTenant(t *testing.T) {
	resources := testTenantResources(t, "tenant-a", failClosedDB)
	svc := mustNewTenantService(t, testTenantResourcesProvider{resources.Info.ID: resources})

	if _, err := svc.DetachContext(context.Background()); !errors.Is(err, tenant.ErrRequired) {
		t.Fatalf("missing tenant error = %v, want %v", err, tenant.ErrRequired)
	}

	forged := resources.Info.Clone()
	forged.CanonicalBaseURL = "https://attacker.example.test"
	if _, err := svc.DetachContext(tenant.WithInfo(context.Background(), forged)); !errors.Is(err, ErrTenantResourceMismatch) {
		t.Fatalf("forged tenant error = %v, want %v", err, ErrTenantResourceMismatch)
	}
}
