package automation

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"testing"
	"time"

	automationstore "github.com/tidbcloud/AgentTeam/internal/store/automation"
	"github.com/tidbcloud/AgentTeam/internal/store/resource/repos"

	"github.com/stretchr/testify/require"
)

func TestAGSHTTPAutomationSourceResolverCapsAutomationFileResponse(t *testing.T) {
	resolver := AGSHTTPAutomationSourceResolver{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			require.Equal(t, "Bearer source-token", r.Header.Get("X-AgentTeam-Verified-JWT"))
			require.Empty(t, r.Header.Get("Authorization"))
			require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
				"type":     "file",
				"name":     "build.yml",
				"path":     ".agentteam/build.yml",
				"size":     1,
				"encoding": "base64",
				"content":  strings.Repeat("A", 80*1024),
			}))
		}),
		AuthHeader:               "X-AgentTeam-Verified-JWT",
		MaxAutomationSourceBytes: 8,
	}

	_, err := resolver.GetAutomationFile(context.Background(), AutomationSourceRequest{
		RepositoryOwner:     "pingcap",
		RepositoryName:      "agentteam",
		DefinitionPath:      ".agentteam/build.yml",
		AuthorizationHeader: "Bearer source-token",
	})
	require.ErrorContains(t, err, "automation source response exceeds maximum size")
}

func TestAutomationSourcePathRequiresAutomationYAMLExtension(t *testing.T) {
	for _, tc := range []struct {
		name string
		path string
		want bool
	}{
		{name: "yml", path: ".agentteam/build.yml", want: true},
		{name: "yaml", path: "/.agentteam/build.yaml", want: true},
		{name: "wrong_extension", path: ".agentteam/build.txt", want: false},
		{name: "traversal", path: ".agentteam/../build.yml", want: false},
		{name: "outside_directory", path: "automation/build.yml", want: false},
	} {
		t.Run(tc.name, func(t *testing.T) {
			require.Equal(t, tc.want, isAutomationSourcePath(tc.path))
		})
	}
}

func TestAGSHTTPAutomationSourceResolverListsAgentTeamYAMLRecursively(t *testing.T) {
	resolver := AGSHTTPAutomationSourceResolver{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodGet, r.Method)
		switch r.URL.Path {
		case "/api/v3/repos/pingcap/agentteam/contents/.agentteam":
			require.Equal(t, "main", r.URL.Query().Get("ref"))
			require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{
				{"type": "file", "name": "top.yml", "path": ".agentteam/top.yml", "sha": "sha-top", "size": 10},
				{"type": "file", "name": "README.md", "path": ".agentteam/README.md", "sha": "sha-readme", "size": 9},
				{"type": "dir", "name": "ops", "path": ".agentteam/ops"},
			}))
		case "/api/v3/repos/pingcap/agentteam/contents/.agentteam/ops":
			require.Equal(t, "main", r.URL.Query().Get("ref"))
			require.NoError(t, json.NewEncoder(w).Encode([]map[string]any{
				{"type": "file", "name": "deploy.yaml", "path": ".agentteam/ops/deploy.yaml", "sha": "sha-deploy", "size": 20},
				{"type": "file", "name": "notes.txt", "path": ".agentteam/ops/notes.txt", "sha": "sha-notes", "size": 5},
			}))
		default:
			http.NotFound(w, r)
		}
	})}

	files, err := resolver.ListAutomationFiles(context.Background(), AutomationSourceRequest{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		GitRef:          "main",
	})
	require.NoError(t, err)
	require.Equal(t, []string{".agentteam/ops/deploy.yaml", ".agentteam/top.yml"}, automationSourceFilePaths(files))
}

func TestAutomationRepositoriesIncludeWorkspaceKBOrgs(t *testing.T) {
	ctx := context.Background()
	store := newAutomationsServiceStoreForSourceTest(t)
	resolver := &recordingAutomationSourceControl{}
	service := NewAutomationsService(store, func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)

	repos, err := service.ListAutomationRepositories(ctx, "wsp_1", "user-1", "Bearer source-token", 1, 50)
	require.NoError(t, err)
	require.Equal(t, []string{"wsp_1"}, resolver.orgs)
	require.Equal(t, []string{"user/repo", "wsp_1/handbook.kb"}, automationRepoFullNames(repos))
}

func automationSourceFilePaths(files []AutomationSourceFile) []string {
	out := make([]string, 0, len(files))
	for _, file := range files {
		out = append(out, file.Path)
	}
	return out
}

func TestSaveRepositoryAutomationFileUsesAGSContentsSemantics(t *testing.T) {
	ctx := context.Background()
	ags := newMemoryAutomationContentsHandler(t)
	resolver := AGSHTTPAutomationSourceResolver{Handler: ags}
	definitionPath := ".agentteam/build.yml"
	content := "name: Build\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo hi\n"

	created, err := resolver.PutAutomationFile(ctx, AutomationSourceWriteRequest{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  definitionPath,
		Branch:          "main",
		Message:         "create automation",
		Content:         content,
	})
	require.NoError(t, err)
	require.True(t, created.Created)
	require.Equal(t, "sha-1", created.Content.SHA)
	require.Equal(t, content, ags.files[definitionPath].content)

	_, err = resolver.PutAutomationFile(ctx, AutomationSourceWriteRequest{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  definitionPath,
		Branch:          "main",
		Message:         "missing sha",
		Content:         strings.Replace(content, "echo hi", "echo bye", 1),
	})
	require.Error(t, err)
	require.True(t, errors.Is(err, ErrInvalidArgument))
	require.Equal(t, content, ags.files[definitionPath].content)

	_, err = resolver.PutAutomationFile(ctx, AutomationSourceWriteRequest{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  definitionPath,
		Branch:          "main",
		Message:         "wrong sha",
		Content:         strings.Replace(content, "echo hi", "echo bye", 1),
		SHA:             "wrong-sha",
	})
	require.Error(t, err)
	require.True(t, errors.Is(err, ErrConflict))
	require.Equal(t, content, ags.files[definitionPath].content)

	updatedContent := strings.Replace(content, "echo hi", "echo bye", 1)
	updated, err := resolver.PutAutomationFile(ctx, AutomationSourceWriteRequest{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  definitionPath,
		Branch:          "main",
		Message:         "update automation",
		Content:         updatedContent,
		SHA:             created.Content.SHA,
	})
	require.NoError(t, err)
	require.False(t, updated.Created)
	require.Equal(t, "sha-2", updated.Content.SHA)
	require.Equal(t, updatedContent, ags.files[definitionPath].content)
}

func TestDeleteRepositoryAutomationFileUsesAGSContentsSemantics(t *testing.T) {
	ctx := context.Background()
	ags := newMemoryAutomationContentsHandler(t)
	resolver := AGSHTTPAutomationSourceResolver{Handler: ags}
	definitionPath := ".agentteam/build.yml"
	content := "name: Build\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo hi\n"
	created, err := resolver.PutAutomationFile(ctx, AutomationSourceWriteRequest{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  definitionPath,
		Branch:          "main",
		Message:         "create automation",
		Content:         content,
	})
	require.NoError(t, err)

	err = resolver.DeleteAutomationFile(ctx, AutomationSourceDeleteRequest{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  definitionPath,
		Branch:          "main",
		Message:         "missing sha",
	})
	require.Error(t, err)
	require.True(t, errors.Is(err, ErrInvalidArgument))
	require.Contains(t, ags.files, definitionPath)

	err = resolver.DeleteAutomationFile(ctx, AutomationSourceDeleteRequest{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  definitionPath,
		Branch:          "main",
		Message:         "wrong sha",
		SHA:             "wrong-sha",
	})
	require.Error(t, err)
	require.True(t, errors.Is(err, ErrConflict))
	require.Contains(t, ags.files, definitionPath)

	err = resolver.DeleteAutomationFile(ctx, AutomationSourceDeleteRequest{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  definitionPath,
		Branch:          "main",
		Message:         "delete automation",
		SHA:             created.Content.SHA,
	})
	require.NoError(t, err)
	require.NotContains(t, ags.files, definitionPath)
}

func TestSaveRepositoryAutomationFileValidatesBeforeWrite(t *testing.T) {
	ctx := context.Background()
	valid := "name: Build\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo hi\n"
	cases := []struct {
		name    string
		path    string
		content string
		limit   int
	}{
		{name: "invalid path", path: ".agentteam/../automations/build.yml", content: valid, limit: 1024},
		{name: "invalid yaml", path: ".agentteam/build.yml", content: "jobs: [", limit: 1024},
		{name: "unsupported field", path: ".agentteam/build.yml", content: "name: Bad\non:\n  manual: {}\njobs:\n  build:\n    strategy:\n      matrix:\n        go: [\"1.25\"]\n    steps:\n      - run: echo hi\n", limit: 1024},
		{name: "source limit", path: ".agentteam/build.yml", content: valid, limit: 8},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			resolver := &recordingAutomationSourceControl{}
			service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)
			service.SetLimits(AutomationLimits{MaxAutomationSourceBytes: tc.limit})
			_, err := service.SaveRepositoryAutomationFile(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationSaveMutation{
				RepositoryOwner: "pingcap",
				RepositoryName:  "agentteam",
				DefinitionPath:  tc.path,
				Branch:          "main",
				Content:         tc.content,
			})
			require.Error(t, err)
			require.Empty(t, resolver.putRequests)
		})
	}
}

func TestDeleteRepositoryAutomationFileValidatesBeforeWrite(t *testing.T) {
	ctx := context.Background()
	cases := []struct {
		name   string
		path   string
		sha    string
		branch string
		ref    string
	}{
		{name: "invalid path", path: ".agentteam/../automations/build.yml", sha: "sha-old", ref: "main"},
		{name: "missing sha", path: ".agentteam/build.yml", ref: "main"},
		{name: "branch ref mismatch", path: ".agentteam/build.yml", sha: "sha-old", branch: "main", ref: "release"},
		{name: "tag ref", path: ".agentteam/build.yml", sha: "sha-old", ref: "refs/tags/v1.0.0"},
		{name: "commit sha", path: ".agentteam/build.yml", sha: "sha-old", ref: "0123456789abcdef0123456789abcdef01234567"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			resolver := &recordingAutomationSourceControl{}
			service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)
			err := service.DeleteRepositoryAutomationFile(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationDeleteMutation{
				RepositoryOwner: "pingcap",
				RepositoryName:  "agentteam",
				DefinitionPath:  tc.path,
				Branch:          tc.branch,
				GitRef:          tc.ref,
				SHA:             tc.sha,
			})
			require.Error(t, err)
			require.True(t, errors.Is(err, ErrInvalidArgument))
			require.Empty(t, resolver.deleteRequests)
		})
	}
}

func TestSaveRepositoryAutomationFileNormalizesRefAlias(t *testing.T) {
	ctx := context.Background()
	resolver := &recordingAutomationSourceControl{putResult: AutomationSourceWriteResult{
		Content: AutomationSourceSavedFile{AutomationSourceFile: AutomationSourceFile{RepositoryOwner: "pingcap", RepositoryName: "agentteam", Path: ".agentteam/build.yml", Name: "build.yml", SHA: "sha-new", Size: 92, Ref: "main"}, Encoding: "utf-8"},
		Commit:  AutomationSourceCommit{SHA: "commit-new", Message: "save"},
		Created: true,
	}}
	service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)
	content := "name: Build\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo hi\n"

	result, err := service.SaveRepositoryAutomationFile(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationSaveMutation{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  "/.agentteam/build.yml",
		GitRef:          "refs/heads/main",
		Message:         "save",
		Content:         content,
	})
	require.NoError(t, err)
	require.True(t, result.Created)
	require.Len(t, resolver.putRequests, 1)
	require.Equal(t, "main", resolver.putRequests[0].Branch)
	require.Equal(t, ".agentteam/build.yml", resolver.putRequests[0].DefinitionPath)
	require.Equal(t, content, resolver.putRequests[0].Content)
}

func TestListRepositoryAutomationFilesPersistsAutomationRepositoryBinding(t *testing.T) {
	ctx := context.Background()
	resolver := readOnlyAutomationSourceControl{files: []AutomationSourceFile{}}
	service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)
	repoStore := newAutomationRepositoryStoreForSourceTest(t)
	service.SetRepositoryStore(repoStore)

	files, err := service.ListRepositoryAutomationFiles(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationQuery{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
	})
	require.NoError(t, err)
	require.Empty(t, files)

	binding, err := repoStore.Get(ctx, "wsp_1", repos.ResourceTypeAutomationRepository, repos.AutomationRepositoryResourceID("pingcap/agentteam"), repos.RepoRolePrimary)
	require.NoError(t, err)
	require.Equal(t, "pingcap/agentteam", binding.RepoFullName)
	require.Equal(t, "automation_repository", binding.OwnerKind)
}

func TestSaveRepositoryAutomationFilePersistsAutomationRepositoryBinding(t *testing.T) {
	ctx := context.Background()
	resolver := &recordingAutomationSourceControl{putResult: AutomationSourceWriteResult{
		Content: AutomationSourceSavedFile{AutomationSourceFile: AutomationSourceFile{RepositoryOwner: "pingcap", RepositoryName: "agentteam", Path: ".agentteam/build.yml", Name: "build.yml", SHA: "sha-new", Size: 92, Ref: "main"}, Encoding: "utf-8"},
		Commit:  AutomationSourceCommit{SHA: "commit-new", Message: "save"},
		Created: true,
	}}
	service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)
	repoStore := newAutomationRepositoryStoreForSourceTest(t)
	service.SetRepositoryStore(repoStore)

	_, err := service.SaveRepositoryAutomationFile(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationSaveMutation{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  ".agentteam/build.yml",
		GitRef:          "refs/heads/main",
		Message:         "save",
		Content:         "name: Build\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo hi\n",
	})
	require.NoError(t, err)

	binding, err := repoStore.Get(ctx, "wsp_1", repos.ResourceTypeAutomationRepository, repos.AutomationRepositoryResourceID("pingcap/agentteam"), repos.RepoRolePrimary)
	require.NoError(t, err)
	require.Equal(t, "pingcap/agentteam", binding.RepoFullName)
	require.Equal(t, "main", binding.DefaultRef)
	require.Equal(t, "user-1", binding.CreatedBy)
	require.Equal(t, "user-1", binding.UpdatedBy)
}

func TestDeleteRepositoryAutomationFileNormalizesRefAlias(t *testing.T) {
	ctx := context.Background()
	resolver := &recordingAutomationSourceControl{}
	service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)

	err := service.DeleteRepositoryAutomationFile(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationDeleteMutation{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  "/.agentteam/build.yml",
		GitRef:          "refs/heads/main",
		SHA:             "sha-old",
	})
	require.NoError(t, err)
	require.Len(t, resolver.deleteRequests, 1)
	require.Equal(t, "main", resolver.deleteRequests[0].Branch)
	require.Equal(t, ".agentteam/build.yml", resolver.deleteRequests[0].DefinitionPath)
	require.Equal(t, "Delete .agentteam/build.yml", resolver.deleteRequests[0].Message)
	require.Equal(t, "sha-old", resolver.deleteRequests[0].SHA)
}

func TestSaveRepositoryAutomationFileRejectsNonBranchRefs(t *testing.T) {
	ctx := context.Background()
	content := "name: Build\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo hi\n"
	cases := []struct {
		name   string
		branch string
		ref    string
	}{
		{name: "branch ref mismatch", branch: "main", ref: "release"},
		{name: "tag ref", ref: "refs/tags/v1.0.0"},
		{name: "commit sha", ref: "0123456789abcdef0123456789abcdef01234567"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			resolver := &recordingAutomationSourceControl{}
			service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)
			_, err := service.SaveRepositoryAutomationFile(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationSaveMutation{
				RepositoryOwner: "pingcap",
				RepositoryName:  "agentteam",
				DefinitionPath:  ".agentteam/build.yml",
				Branch:          tc.branch,
				GitRef:          tc.ref,
				Content:         content,
			})
			require.Error(t, err)
			require.True(t, errors.Is(err, ErrInvalidArgument))
			require.Empty(t, resolver.putRequests)
		})
	}
}

func TestRepositoryAutomationReadsDoNotRequireWriteControlPlane(t *testing.T) {
	ctx := context.Background()
	resolver := readOnlyAutomationSourceControl{
		files: []AutomationSourceFile{{RepositoryOwner: "pingcap", RepositoryName: "agentteam", Path: ".agentteam/build.yml", Name: "build.yml", SHA: "sha-1", Size: 92, Ref: "main"}},
		content: AutomationSourceContent{
			AutomationSourceFile: AutomationSourceFile{RepositoryOwner: "pingcap", RepositoryName: "agentteam", Path: ".agentteam/build.yml", Name: "build.yml", SHA: "sha-1", Size: 92, Ref: "main"},
			Content:              "name: Build\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo hi\n",
			Encoding:             "utf-8",
		},
	}
	service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)

	files, err := service.ListRepositoryAutomationFiles(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationQuery{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
	})
	require.NoError(t, err)
	require.Equal(t, resolver.files, files)

	content, err := service.GetRepositoryAutomationFile(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationQuery{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  ".agentteam/build.yml",
	})
	require.NoError(t, err)
	require.Equal(t, resolver.content, content)

	_, err = service.SaveRepositoryAutomationFile(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationSaveMutation{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  ".agentteam/build.yml",
		Content:         resolver.content.Content,
	})
	require.ErrorContains(t, err, "Automation source write control plane is not configured")

	err = service.DeleteRepositoryAutomationFile(ctx, "wsp_1", "user-1", AutomationRepositoryAutomationDeleteMutation{
		RepositoryOwner: "pingcap",
		RepositoryName:  "agentteam",
		DefinitionPath:  ".agentteam/build.yml",
		SHA:             "sha-old",
	})
	require.ErrorContains(t, err, "Automation source write control plane is not configured")
}

func TestAutomationRepositoriesSourceScanWindowHasProductCap(t *testing.T) {
	ctx := context.Background()
	repos := automationSourceRepositoryFixtures("user", MaxAutomationRepositorySourceCandidates)
	orgRepos := map[string][]AutomationSourceRepository{
		"wsp_1": automationSourceRepositoryFixtures("wsp_1", MaxAutomationRepositorySourceCandidates),
	}
	resolver := &recordingAutomationSourceControl{repos: repos, orgRepos: orgRepos}
	service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)

	result, err := service.ListAutomationRepositories(ctx, "wsp_1", "user-1", "Bearer source-token", 100000, 100)
	require.NoError(t, err)
	require.Empty(t, result)
	require.Equal(t, MaxAutomationRepositorySourceCandidates, automationRepositorySourceScanWindow(100000, 100))
	require.Len(t, resolver.listCalls, MaxAutomationRepositorySourceCandidates/100)
	require.Len(t, resolver.orgCalls, MaxAutomationRepositorySourceCandidates/100)
}

func TestAutomationRepositoriesKBPaginationIsBounded(t *testing.T) {
	ctx := context.Background()
	kbRepos := make([]AutomationSourceRepository, 0, 120)
	for i := 0; i < 120; i++ {
		name := fmt.Sprintf("kb-%03d", i)
		kbRepos = append(kbRepos, AutomationSourceRepository{
			Owner:         "wsp_1",
			Name:          name + ".kb",
			FullName:      "wsp_1/" + name + ".kb",
			DefaultBranch: "main",
		})
	}
	resolver := &recordingAutomationSourceControl{repos: []AutomationSourceRepository{}, orgRepos: map[string][]AutomationSourceRepository{"wsp_1": kbRepos}}
	service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)

	pageOne, err := service.ListAutomationRepositories(ctx, "wsp_1", "user-1", "Bearer source-token", 1, 25)
	require.NoError(t, err)
	require.Len(t, pageOne, 25)

	pageTwo, err := service.ListAutomationRepositories(ctx, "wsp_1", "user-1", "Bearer source-token", 2, 100)
	require.NoError(t, err)
	names := automationRepoFullNames(pageTwo)
	require.Contains(t, names, "wsp_1/kb-119.kb")
	require.Len(t, pageTwo, 20)

	pageOneLarge, err := service.ListAutomationRepositories(ctx, "wsp_1", "user-1", "Bearer source-token", 1, 1000)
	require.NoError(t, err)
	require.Contains(t, automationRepoFullNames(pageOneLarge), "wsp_1/kb-119.kb")
	require.Len(t, pageOneLarge, 120)
}

func TestAutomationRepositoriesDeduplicateKBRepos(t *testing.T) {
	ctx := context.Background()
	launchRepo := AutomationSourceRepository{Owner: "wsp_1", Name: "launch.kb", FullName: "wsp_1/launch.kb", DefaultBranch: "main"}
	resolver := &recordingAutomationSourceControl{
		repos:    []AutomationSourceRepository{launchRepo},
		orgRepos: map[string][]AutomationSourceRepository{"wsp_1": []AutomationSourceRepository{launchRepo}},
	}
	service := NewAutomationsService(newAutomationsServiceStoreForSourceTest(t), func() time.Time { return time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC) }, resolver)

	repos, err := service.ListAutomationRepositories(ctx, "wsp_1", "user-1", "Bearer source-token", 1, 10)
	require.NoError(t, err)
	require.Equal(t, []string{"wsp_1/launch.kb"}, automationRepoFullNames(repos))
}

func newAutomationsServiceStoreForSourceTest(t *testing.T) *automationstore.Store {
	t.Helper()
	db, err := openServiceTestSQLiteDB(t)
	require.NoError(t, err)
	migrateAutomationServiceTestSchema(t, db, &automationstore.AutomationPinnedRepo{})
	return automationstore.New(db)
}

func newAutomationRepositoryStoreForSourceTest(t *testing.T) *repos.Store {
	t.Helper()
	db, err := openServiceTestSQLiteDB(t, "repos")
	require.NoError(t, err)
	require.NoError(t, db.AutoMigrate(&repos.ResourceRepository{}, &repos.ResourceRepositoryLifecycleEvent{}))
	return repos.New(db)
}

type recordingAutomationSourceControl struct {
	repos          []AutomationSourceRepository
	orgRepos       map[string][]AutomationSourceRepository
	orgs           []string
	listCalls      []automationRepoListCall
	orgCalls       []automationRepoOrgListCall
	putRequests    []AutomationSourceWriteRequest
	deleteRequests []AutomationSourceDeleteRequest
	putResult      AutomationSourceWriteResult
	putErr         error
	deleteErr      error
}

type automationRepoListCall struct {
	page    int
	perPage int
}

type automationRepoOrgListCall struct {
	org     string
	page    int
	perPage int
}

func (r *recordingAutomationSourceControl) ResolveAutomationSource(context.Context, AutomationSourceRequest) (AutomationSourceSnapshot, error) {
	return AutomationSourceSnapshot{}, nil
}

func (r *recordingAutomationSourceControl) ListAutomationRepositories(_ context.Context, _ string, page int, perPage int) ([]AutomationSourceRepository, error) {
	r.listCalls = append(r.listCalls, automationRepoListCall{page: page, perPage: perPage})
	if r.repos != nil {
		return automationSourceRepositoryFixturePage(r.repos, page, perPage), nil
	}
	return []AutomationSourceRepository{{Owner: "user", Name: "repo", FullName: "user/repo", DefaultBranch: "main"}}, nil
}

func (r *recordingAutomationSourceControl) ListAutomationOrgRepositories(_ context.Context, _ string, org string, page int, perPage int) ([]AutomationSourceRepository, error) {
	r.orgs = append(r.orgs, org)
	r.orgCalls = append(r.orgCalls, automationRepoOrgListCall{org: org, page: page, perPage: perPage})
	if r.orgRepos != nil {
		return automationSourceRepositoryFixturePage(r.orgRepos[org], page, perPage), nil
	}
	switch org {
	case "wsp_1":
		return []AutomationSourceRepository{{Owner: org, Name: "handbook.kb", FullName: org + "/handbook.kb", DefaultBranch: "main"}}, nil
	default:
		return nil, nil
	}
}

func automationSourceRepositoryFixturePage(repos []AutomationSourceRepository, page int, perPage int) []AutomationSourceRepository {
	if page < 1 {
		page = 1
	}
	if perPage < 1 {
		perPage = 100
	}
	start := (page - 1) * perPage
	if start >= len(repos) {
		return []AutomationSourceRepository{}
	}
	end := start + perPage
	if end > len(repos) {
		end = len(repos)
	}
	return repos[start:end]
}

func (r *recordingAutomationSourceControl) ListAutomationFiles(context.Context, AutomationSourceRequest) ([]AutomationSourceFile, error) {
	return nil, nil
}

func (r *recordingAutomationSourceControl) GetAutomationFile(context.Context, AutomationSourceRequest) (AutomationSourceContent, error) {
	return AutomationSourceContent{}, nil
}

func (r *recordingAutomationSourceControl) PutAutomationFile(_ context.Context, request AutomationSourceWriteRequest) (AutomationSourceWriteResult, error) {
	r.putRequests = append(r.putRequests, request)
	if r.putErr != nil {
		return AutomationSourceWriteResult{}, r.putErr
	}
	if r.putResult.Commit.SHA != "" || r.putResult.Content.Path != "" {
		return r.putResult, nil
	}
	return AutomationSourceWriteResult{
		Content: AutomationSourceSavedFile{AutomationSourceFile: AutomationSourceFile{RepositoryOwner: request.RepositoryOwner, RepositoryName: request.RepositoryName, Path: request.DefinitionPath, Name: automationSourceFileName(request.DefinitionPath), SHA: "sha-new", Size: int64(len([]byte(request.Content))), Ref: request.Branch}, Encoding: "utf-8"},
		Commit:  AutomationSourceCommit{SHA: "commit-new", Message: request.Message},
		Created: true,
	}, nil
}

func (r *recordingAutomationSourceControl) DeleteAutomationFile(_ context.Context, request AutomationSourceDeleteRequest) error {
	r.deleteRequests = append(r.deleteRequests, request)
	return r.deleteErr
}

type readOnlyAutomationSourceControl struct {
	files   []AutomationSourceFile
	content AutomationSourceContent
}

func (r readOnlyAutomationSourceControl) ResolveAutomationSource(context.Context, AutomationSourceRequest) (AutomationSourceSnapshot, error) {
	return AutomationSourceSnapshot{}, nil
}

func (r readOnlyAutomationSourceControl) ListAutomationRepositories(context.Context, string, int, int) ([]AutomationSourceRepository, error) {
	return nil, nil
}

func (r readOnlyAutomationSourceControl) ListAutomationOrgRepositories(context.Context, string, string, int, int) ([]AutomationSourceRepository, error) {
	return nil, nil
}

func (r readOnlyAutomationSourceControl) ListAutomationFiles(context.Context, AutomationSourceRequest) ([]AutomationSourceFile, error) {
	return r.files, nil
}

func (r readOnlyAutomationSourceControl) GetAutomationFile(context.Context, AutomationSourceRequest) (AutomationSourceContent, error) {
	return r.content, nil
}

func automationRepoFullNames(repos []AutomationSourceRepository) []string {
	out := make([]string, 0, len(repos))
	for _, repo := range repos {
		out = append(out, repo.FullName)
	}
	return out
}

func automationSourceRepositoryFixtures(owner string, count int) []AutomationSourceRepository {
	repos := make([]AutomationSourceRepository, 0, count)
	for i := 0; i < count; i++ {
		name := fmt.Sprintf("repo-%03d", i)
		repos = append(repos, AutomationSourceRepository{Owner: owner, Name: name, FullName: owner + "/" + name, DefaultBranch: "main"})
	}
	return repos
}

type memoryAutomationContentsHandler struct {
	t     *testing.T
	files map[string]memoryAutomationContent
	puts  int
}

type memoryAutomationContent struct {
	content string
	sha     string
}

func newMemoryAutomationContentsHandler(t *testing.T) *memoryAutomationContentsHandler {
	t.Helper()
	return &memoryAutomationContentsHandler{t: t, files: map[string]memoryAutomationContent{}}
}

func (h *memoryAutomationContentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	h.t.Helper()
	if !strings.HasPrefix(r.URL.Path, "/api/v3/repos/pingcap/agentteam/contents/") {
		http.NotFound(w, r)
		return
	}
	definitionPath := strings.TrimPrefix(r.URL.Path, "/api/v3/repos/pingcap/agentteam/contents/")
	switch r.Method {
	case http.MethodPut:
		h.put(w, r, definitionPath)
	case http.MethodDelete:
		h.delete(w, r, definitionPath)
	default:
		http.NotFound(w, r)
	}
}

func (h *memoryAutomationContentsHandler) put(w http.ResponseWriter, r *http.Request, definitionPath string) {
	h.t.Helper()
	var body struct {
		Message string `json:"message"`
		Content string `json:"content"`
		Branch  string `json:"branch"`
		SHA     string `json:"sha"`
	}
	require.NoError(h.t, json.NewDecoder(r.Body).Decode(&body))
	require.Equal(h.t, "main", body.Branch)
	decoded, err := base64.StdEncoding.DecodeString(body.Content)
	require.NoError(h.t, err)
	current, exists := h.files[definitionPath]
	if exists && strings.TrimSpace(body.SHA) == "" {
		writeMemoryAGSError(w, http.StatusUnprocessableEntity, "sha is required when updating an existing file")
		return
	}
	if exists && body.SHA != current.sha {
		writeMemoryAGSError(w, http.StatusConflict, "sha does not match current file")
		return
	}
	h.puts++
	newSHA := fmt.Sprintf("sha-%d", h.puts)
	h.files[definitionPath] = memoryAutomationContent{content: string(decoded), sha: newSHA}
	status := http.StatusCreated
	if exists {
		status = http.StatusOK
	}
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]any{
		"content": map[string]any{
			"type": "file",
			"name": automationSourceFileName(definitionPath),
			"path": definitionPath,
			"sha":  newSHA,
			"size": len(decoded),
		},
		"commit": map[string]any{
			"sha":     "commit-" + newSHA,
			"message": body.Message,
		},
	})
}

func (h *memoryAutomationContentsHandler) delete(w http.ResponseWriter, r *http.Request, definitionPath string) {
	h.t.Helper()
	var body struct {
		Message string `json:"message"`
		Branch  string `json:"branch"`
		SHA     string `json:"sha"`
	}
	require.NoError(h.t, json.NewDecoder(r.Body).Decode(&body))
	require.Equal(h.t, "main", body.Branch)
	if strings.TrimSpace(body.SHA) == "" {
		writeMemoryAGSError(w, http.StatusUnprocessableEntity, "sha is required when deleting an existing file")
		return
	}
	current, exists := h.files[definitionPath]
	if !exists {
		writeMemoryAGSError(w, http.StatusNotFound, "file not found")
		return
	}
	if body.SHA != current.sha {
		writeMemoryAGSError(w, http.StatusConflict, "sha does not match current file")
		return
	}
	delete(h.files, definitionPath)
	w.WriteHeader(http.StatusOK)
	_ = json.NewEncoder(w).Encode(map[string]any{
		"content": nil,
		"commit": map[string]any{
			"sha":     "delete-" + current.sha,
			"message": body.Message,
		},
	})
}

func writeMemoryAGSError(w http.ResponseWriter, status int, message string) {
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]any{"message": message, "error": message})
}
