package skills

import (
	"bytes"
	"context"
	"math"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/service/resource/activity"

	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/api/meshxapi"
	"github.com/tidbcloud/AgentTeam/internal/identity/trusted"
	"github.com/tidbcloud/AgentTeam/internal/service/resource/repos"
	skillservice "github.com/tidbcloud/AgentTeam/internal/service/skills"
	reposstore "github.com/tidbcloud/AgentTeam/internal/store/resource/repos"
	store "github.com/tidbcloud/AgentTeam/internal/store/resource/skills"
)

type fakeSkillService struct {
	createInput    skillservice.SkillCreateMutation
	importInput    skillservice.SkillContentImportMutation
	backupInput    skillservice.SkillRepositoryBackupCreateMutation
	restoreTagName string
	backupQuery    string
	backupPage     int
	backupPerPage  int
	backupErr      error
	versionSkillID string
	versionPage    int
	versionPerPage int
	versionErr     error
	rollbackID     string
	updateInput    skillservice.SkillUpdateMutation
	skill          store.Skill
	repos          skillservice.SkillRepositories
	count          int64
	bindInput      skillservice.AgentSkillBindingMutation
	batchInput     []string
	listOptions    skillservice.SkillListOptions
	bindingOptions skillservice.AgentSkillBindingsOptions
	bindingAgentID string
	batchOptions   skillservice.AgentSkillAccessBatchOptions
	batchToken     string
	batchExpiry    time.Time
	bindingsErr    error
	readInput      skillservice.SkillRepositoryContentsRequest
	readErr        error

	activityOpts activity.ResourceActivityListOptions
	activityPage activity.ResourceActivityPage

	permissionSkillID string
	permissionGrantID string
	permissionInput   skillservice.SkillPermissionGrantMutation
}

func (s *fakeSkillService) CreateSkill(_ context.Context, _, _ string, input skillservice.SkillCreateMutation) (store.Skill, error) {
	s.createInput = input
	return s.skill, nil
}
func (s *fakeSkillService) ImportSkillContent(_ context.Context, _, _, id string, input skillservice.SkillContentImportMutation) (skillservice.SkillContentImportResult, error) {
	s.importInput = input
	skill := s.skill
	if skill.ID == "" {
		skill.ID = id
	}
	return skillservice.SkillContentImportResult{
		Skill:        skill,
		Repositories: s.repos,
		CommitSHA:    "def456",
		SkillCount:   1,
		ImportedSkills: []skillservice.SkillRepositoryDetectedSkill{{
			Name:        "backend-sk",
			Description: "Backend skill",
			Path:        "skills/backend/SKILL.md",
			Layout:      skillservice.SkillRepositoryLayoutTemplate,
			SizeBytes:   42,
			Status:      skillservice.SkillRepositorySkillStatusValid,
			IssueCode:   skillservice.SkillRepositoryIssueOK,
		}},
		Warnings: []skillservice.SkillImportWarning{{
			Code:       skillservice.SkillImportWarningArchiveEntrySkippedOversize,
			Message:    "archive entry was skipped",
			Path:       "repo-main/fixtures/large.json",
			SizeBytes:  16777217,
			LimitBytes: 16777216,
		}},
	}, nil
}
func (s *fakeSkillService) ListSkillRepositoryBackups(_ context.Context, _, _, _ string, query string, page, perPage int) (skillservice.SkillRepositoryBackupPage, error) {
	s.backupQuery = query
	s.backupPage = page
	s.backupPerPage = perPage
	if s.backupErr != nil {
		return skillservice.SkillRepositoryBackupPage{}, s.backupErr
	}
	return skillservice.SkillRepositoryBackupPage{
		Items: []skillservice.SkillRepositoryBackup{{
			TagName:   "memory-20260708-010203",
			Name:      "Before import",
			CommitSHA: "abc123",
			CreatedAt: time.Date(2026, 7, 8, 1, 2, 3, 0, time.UTC),
		}},
		Page:    page,
		PerPage: perPage,
		HasMore: true,
	}, nil
}
func (s *fakeSkillService) CreateSkillRepositoryBackup(_ context.Context, _, _, _ string, input skillservice.SkillRepositoryBackupCreateMutation) (skillservice.SkillRepositoryBackup, error) {
	s.backupInput = input
	return skillservice.SkillRepositoryBackup{TagName: "memory-20260708-010203", Name: input.Name, CommitSHA: "abc123", CreatedAt: time.Date(2026, 7, 8, 1, 2, 3, 0, time.UTC)}, nil
}
func (s *fakeSkillService) RestoreSkillRepositoryBackup(_ context.Context, _, _, _ string, tagName string) (skillservice.SkillRepositoryBackup, error) {
	s.restoreTagName = tagName
	return skillservice.SkillRepositoryBackup{TagName: tagName, Name: "Before import", CommitSHA: "abc123", CreatedAt: time.Date(2026, 7, 8, 1, 2, 3, 0, time.UTC)}, nil
}
func (s *fakeSkillService) ListSkillVersions(_ context.Context, _, _, skillID string, page, perPage int) (skillservice.SkillVersionPage, error) {
	s.versionSkillID = skillID
	s.versionPage = page
	s.versionPerPage = perPage
	if s.versionErr != nil {
		return skillservice.SkillVersionPage{}, s.versionErr
	}
	return skillservice.SkillVersionPage{Items: []skillservice.SkillVersion{{
		ID: "skv_1", ContentID: "ctn_1", ContentVersionID: "ctv_1",
		RootDigest: strings.Repeat("a", 64), FileCount: 2, SizeBytes: 42, SkillCount: 1, CreatedBy: "usr_1", CreatedAt: time.Date(2026, 7, 8, 1, 2, 3, 0, time.UTC),
	}}, Page: page, PerPage: perPage}, nil
}
func (s *fakeSkillService) RollbackSkillVersion(_ context.Context, _, _, skillID, versionID string) (skillservice.SkillVersion, error) {
	s.versionSkillID = skillID
	s.rollbackID = versionID
	return skillservice.SkillVersion{ID: versionID, ContentID: "ctn_1", ContentVersionID: "ctv_2", RootDigest: strings.Repeat("b", 64), FileCount: 2, SizeBytes: 42, SkillCount: 1, CreatedBy: "usr_1", CreatedAt: time.Date(2026, 7, 8, 1, 2, 3, 0, time.UTC)}, nil
}
func (s *fakeSkillService) ListSkills(_ context.Context, _, _ string, options skillservice.SkillListOptions) (skillservice.SkillPage, error) {
	s.listOptions = options
	return skillservice.SkillPage{Items: []store.Skill{s.skill}, Page: 1, PerPage: 50, Total: 1}, nil
}

func (s *fakeSkillService) CountWorkspaceSkills(context.Context, string, string) (int64, error) {
	return s.count, nil
}

func (s *fakeSkillService) ListSkillActivity(_ context.Context, _, _ string, opts activity.ResourceActivityListOptions) (activity.ResourceActivityPage, error) {
	s.activityOpts = opts
	return s.activityPage, nil
}
func (s *fakeSkillService) GetSkill(context.Context, string, string, string) (store.Skill, error) {
	return s.skill, nil
}
func (s *fakeSkillService) GetSkillRepositories(context.Context, string, string, string) (skillservice.SkillRepositories, error) {
	return s.repos, nil
}
func (s *fakeSkillService) ListSkillRepositories(context.Context, string, string, []string) (map[string]skillservice.SkillRepositories, error) {
	return map[string]skillservice.SkillRepositories{s.skill.ID: s.repos}, nil
}
func (s *fakeSkillService) ListSkillRepositorySkills(context.Context, string, string, string) (skillservice.SkillRepositoryScanData, error) {
	return skillservice.SkillRepositoryScanData{Limit: skillservice.SkillRepositoryScanLimit, DetectedSkills: []skillservice.SkillRepositoryDetectedSkill{{Name: "backend-sk", Description: "Backend skill", Path: "skills/backend/SKILL.md", Layout: skillservice.SkillRepositoryLayoutTemplate, SizeBytes: 42, Status: skillservice.SkillRepositorySkillStatusValid, IssueCode: skillservice.SkillRepositoryIssueOK}}}, nil
}
func (s *fakeSkillService) ReadSkillRepositorySkills(_ context.Context, _, _, _ string, input skillservice.SkillRepositoryContentsRequest) (skillservice.SkillRepositoryContents, error) {
	s.readInput = input
	if s.readErr != nil {
		return skillservice.SkillRepositoryContents{}, s.readErr
	}
	items := make([]skillservice.SkillRepositoryContent, 0, len(input.Paths))
	for _, path := range input.Paths {
		items = append(items, skillservice.SkillRepositoryContent{Name: "backend-sk", Path: path, Content: "skill content"})
	}
	return skillservice.SkillRepositoryContents{CommitSHA: input.ExpectedCommitSHA, Items: items}, nil
}
func (s *fakeSkillService) UpdateSkill(_ context.Context, _, _, _ string, input skillservice.SkillUpdateMutation) (store.Skill, error) {
	s.updateInput = input
	return s.skill, nil
}
func (s *fakeSkillService) DeleteSkill(context.Context, string, string, string) error {
	return nil
}
func (s *fakeSkillService) BindAgentSkill(_ context.Context, _, _, agentID, skillID string, input skillservice.AgentSkillBindingMutation) (skillservice.AgentSkillBindingResult, error) {
	s.bindInput = input
	return skillservice.AgentSkillBindingResult{AgentID: agentID, SkillID: skillID, Branch: input.Branch}, nil
}
func (s *fakeSkillService) UnbindAgentSkill(context.Context, string, string, string, string) (skillservice.AgentSkillBindingResult, error) {
	return skillservice.AgentSkillBindingResult{Unbound: true}, nil
}
func (s *fakeSkillService) ListAgentSkillBindings(_ context.Context, _, _ string, options skillservice.AgentSkillBindingsOptions) (skillservice.AgentSkillBindingsSummary, error) {
	s.bindingOptions = options
	if s.bindingsErr != nil {
		return skillservice.AgentSkillBindingsSummary{}, s.bindingsErr
	}
	return skillservice.AgentSkillBindingsSummary{}, nil
}
func (s *fakeSkillService) ListAgentSkillBindingsForAgent(_ context.Context, _, _ string, agentID string) ([]skillservice.AgentSkillBindingSummary, error) {
	s.bindingAgentID = agentID
	return []skillservice.AgentSkillBindingSummary{{
		ID:          "sab_1",
		WorkspaceID: "ws_1",
		AgentID:     agentID,
		SkillID:     "skill_1",
		Branch:      "main",
		CreatedAt:   time.Date(2026, 6, 18, 1, 0, 0, 0, time.UTC),
		UpdatedAt:   time.Date(2026, 6, 18, 1, 0, 0, 0, time.UTC),
	}}, nil
}
func (s *fakeSkillService) BatchListAgentAccessibleSkills(_ context.Context, _, _ string, options skillservice.AgentSkillAccessBatchOptions) (skillservice.AgentSkillAccessBatch, error) {
	s.batchOptions = options
	s.batchInput = options.AgentIDs
	if len(options.AgentIDs) == 0 {
		return skillservice.AgentSkillAccessBatch{Agents: map[string][]skillservice.AgentAccessibleSkill{}, SyncToken: s.batchToken, SyncTokenExpires: s.batchExpiry}, nil
	}
	return skillservice.AgentSkillAccessBatch{Agents: map[string][]skillservice.AgentAccessibleSkill{options.AgentIDs[0]: {}}, SyncToken: s.batchToken, SyncTokenExpires: s.batchExpiry}, nil
}

func (s *fakeSkillService) ListSkillPermissions(context.Context, string, string, string) (skillservice.SkillPermissionsSummary, error) {
	return skillservice.SkillPermissionsSummary{Members: []skillservice.SkillPermissionGrant{}}, nil
}

func (s *fakeSkillService) UpsertSkillPermission(_ context.Context, _, _, skillID string, input skillservice.SkillPermissionGrantMutation) (skillservice.SkillPermissionGrant, error) {
	s.permissionSkillID = skillID
	s.permissionGrantID = input.GrantID
	s.permissionInput = input
	return skillservice.SkillPermissionGrant{ID: firstNonEmptyForTest(input.GrantID, "grant_1"), WorkspaceID: "ws_1", SkillID: skillID, SubjectType: input.SubjectType, SubjectID: input.SubjectID, Role: input.Role}, nil
}

func (s *fakeSkillService) DeleteSkillPermission(_ context.Context, _, _, skillID, grantID string) error {
	s.permissionSkillID = skillID
	s.permissionGrantID = grantID
	return nil
}

func firstNonEmptyForTest(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return value
		}
	}
	return ""
}

func TestRegisterRoutesUseSkillsPrefixWithoutRepos(t *testing.T) {
	registrar := &recordingRegistrar{}
	RegisterRoutes(registrar, NewSkillsHandler(&fakeSkillService{}), nil)
	joined := strings.Join(registrar.patterns, "\n")
	require.Contains(t, joined, "POST /api/v1/skills")
	require.Contains(t, joined, "POST /api/v1/skills/{skill_id}/imports")
	require.Contains(t, joined, "GET /api/v1/skills/{skill_id}/backups")
	require.Contains(t, joined, "POST /api/v1/skills/{skill_id}/backups")
	require.Contains(t, joined, "POST /api/v1/skills/{skill_id}/backups:restore")
	require.Contains(t, joined, "GET /api/v1/skills/{skill_id}/versions")
	require.Contains(t, joined, "POST /api/v1/skills/{skill_id}/versions:rollback")
	require.NotContains(t, joined, "/api/v1/skills/"+"mirror")
	require.Contains(t, joined, "GET /api/v1/skills/{skill_id}/skills")
	require.Contains(t, joined, "POST /api/v1/skills/{skill_id}/contents:read")
	require.NotContains(t, joined, "GET /api/v1/skills/{skill_id}/content")
	require.Contains(t, joined, "GET /api/v1/skills/{skill_id}")
	require.Contains(t, joined, "GET /api/v1/skills/agents/bindings")
	require.Contains(t, joined, "GET /api/v1/skills/agents/{agent_id}/bindings")
	require.Contains(t, joined, "POST /api/v1/skills/agents/access:batch")
	require.Contains(t, joined, "PUT /api/v1/skills/agents/{agent_id}/bindings/{skill_id}")
	require.NotContains(t, joined, "mirror:preview")
	require.NotContains(t, joined, "/repos")
}

func TestSkillVersionRoutesUseExactNativeVersionProjection(t *testing.T) {
	svc := &fakeSkillService{}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)

	listReq := httptest.NewRequest(http.MethodGet, "/api/v1/skills/skill_1/versions?page=2&per_page=7", nil)
	listReq = listReq.WithContext(trusted.WithIdentity(listReq.Context(), trusted.Identity{Subject: "usr_1"}))
	listReq.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	listRec := httptest.NewRecorder()
	mux.ServeHTTP(listRec, listReq)
	require.Equal(t, http.StatusOK, listRec.Code, listRec.Body.String())
	require.Equal(t, "skill_1", svc.versionSkillID)
	require.Equal(t, 2, svc.versionPage)
	require.Equal(t, 7, svc.versionPerPage)
	require.Contains(t, listRec.Body.String(), `"contentId":"ctn_1"`)
	require.Contains(t, listRec.Body.String(), `"contentVersionId":"ctv_1"`)

	rollbackReq := httptest.NewRequest(http.MethodPost, "/api/v1/skills/skill_1/versions:rollback", strings.NewReader(`{"versionId":"skv_1"}`))
	rollbackReq = rollbackReq.WithContext(trusted.WithIdentity(rollbackReq.Context(), trusted.Identity{Subject: "usr_1"}))
	rollbackReq.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rollbackReq.Header.Set("Content-Type", "application/json")
	rollbackRec := httptest.NewRecorder()
	mux.ServeHTTP(rollbackRec, rollbackReq)
	require.Equal(t, http.StatusOK, rollbackRec.Code, rollbackRec.Body.String())
	require.Equal(t, "skill_1", svc.versionSkillID)
	require.Equal(t, "skv_1", svc.rollbackID)
	require.Contains(t, rollbackRec.Body.String(), `"contentVersionId":"ctv_2"`)
}

func TestSkillNativePaginationRoutesReturnBadRequestForMaxIntPage(t *testing.T) {
	extremePage := strconv.Itoa(math.MaxInt)
	for _, test := range []struct {
		name       string
		path       string
		newService func() *fakeSkillService
		page       func(*fakeSkillService) int
	}{
		{name: "versions", path: "/api/v1/skills/skill_1/versions?page=" + extremePage + "&per_page=50", newService: func() *fakeSkillService {
			return &fakeSkillService{versionErr: skillservice.ErrInvalidArgument}
		}, page: func(service *fakeSkillService) int { return service.versionPage }},
		{name: "backups", path: "/api/v1/skills/skill_1/backups?page=" + extremePage + "&per_page=50", newService: func() *fakeSkillService {
			return &fakeSkillService{backupErr: skillservice.ErrInvalidArgument}
		}, page: func(service *fakeSkillService) int { return service.backupPage }},
	} {
		t.Run(test.name, func(t *testing.T) {
			service := test.newService()
			mux := http.NewServeMux()
			RegisterRoutes(mux, NewSkillsHandler(service), nil)
			request := httptest.NewRequest(http.MethodGet, test.path, nil)
			request = request.WithContext(trusted.WithIdentity(request.Context(), trusted.Identity{Subject: "usr_1"}))
			request.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
			response := httptest.NewRecorder()

			mux.ServeHTTP(response, request)

			require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
			require.Equal(t, math.MaxInt, test.page(service), "API parser must pass the bounded service an extreme positive page")
		})
	}
}

func TestSkillImportRouteUsesSkillScopeAndMultipartSource(t *testing.T) {
	now := time.Date(2026, 6, 18, 1, 0, 0, 0, time.UTC)
	svc := &fakeSkillService{
		skill: store.Skill{ID: "skill_1", WorkspaceID: "ws_1", Slug: "reviewer", Name: "Reviewer", IsActive: true, CreatedAt: now, UpdatedAt: now},
		repos: skillservice.SkillRepositories{Primary: &reposstore.ResourceRepository{ID: "rr_1", AGSRepoID: "ags_1", RepoRole: "primary", RepoOwner: "usr_1", RepoName: "reviewer.sk", RepoFullName: "usr_1/reviewer.sk", UpdatedAt: now}},
	}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	part, err := writer.CreateFormFile("file", "skills.zip")
	require.NoError(t, err)
	_, err = part.Write([]byte("fixture zip bytes"))
	require.NoError(t, err)
	require.NoError(t, writer.Close())
	req := httptest.NewRequest(http.MethodPost, "/api/v1/skills/skill_1/imports", &body)
	req.Header.Set("Content-Type", writer.FormDataContentType())
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1", PreferredUsername: "Lloyd User"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Equal(t, "skills.zip", svc.importInput.SourceName)
	require.NotEmpty(t, svc.importInput.SourceFilePath)
	require.Contains(t, rec.Body.String(), `"commitSha":"def456"`)
	require.Contains(t, rec.Body.String(), `"skillCount":1`)
	require.Contains(t, rec.Body.String(), `"path":"skills/backend/SKILL.md"`)
	require.Contains(t, rec.Body.String(), `"warnings"`)
	require.Contains(t, rec.Body.String(), `"code":"ARCHIVE_ENTRY_SKIPPED_OVERSIZE"`)
	require.Contains(t, rec.Body.String(), `"path":"repo-main/fixtures/large.json"`)
	require.Contains(t, rec.Body.String(), `"sizeBytes":16777217`)
	require.Contains(t, rec.Body.String(), `"limitBytes":16777216`)
}

func TestSkillRepositorySkillsRouteIsSkillScoped(t *testing.T) {
	now := time.Date(2026, 6, 18, 1, 0, 0, 0, time.UTC)
	svc := &fakeSkillService{skill: store.Skill{ID: "skill_1", WorkspaceID: "ws_1", Slug: "reviewer", Name: "Reviewer", IsActive: true, CreatedAt: now, UpdatedAt: now}}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodGet, "/api/v1/skills/skill_1/skills", nil)
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Contains(t, rec.Body.String(), `"items"`)
	require.Contains(t, rec.Body.String(), `"path":"skills/backend/SKILL.md"`)
	require.Contains(t, rec.Body.String(), `"layout":"template"`)
}

func TestSkillRepositoryContentsRouteIsSkillScoped(t *testing.T) {
	svc := &fakeSkillService{}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodPost, "/api/v1/skills/skill_1/contents:read", strings.NewReader(`{"paths":["skills/backend/SKILL.md"],"expectedCommitSha":"abc123"}`))
	req.Header.Set("Content-Type", "application/json")
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Equal(t, skillservice.SkillRepositoryContentsRequest{Paths: []string{"skills/backend/SKILL.md"}, ExpectedCommitSHA: "abc123"}, svc.readInput)
	require.Contains(t, rec.Body.String(), `"commitSha":"abc123"`)
	require.Contains(t, rec.Body.String(), `"content":"skill content"`)
}

func TestSkillRepositoryContentsRouteReturnsStableSnapshotConflict(t *testing.T) {
	svc := &fakeSkillService{readErr: skillservice.ErrSkillRepositorySnapshotChanged}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodPost, "/api/v1/skills/skill_1/contents:read", strings.NewReader(`{"paths":["skills/backend/SKILL.md"],"expectedCommitSha":"old"}`))
	req.Header.Set("Content-Type", "application/json")
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusConflict, rec.Code, rec.Body.String())
	require.Contains(t, rec.Body.String(), `"error":"SKILL_REPOSITORY_SNAPSHOT_CHANGED"`)
	require.NotContains(t, rec.Body.String(), `"items"`)
}

func TestSkillRepositoryContentsRouteLimitsRequestBody(t *testing.T) {
	svc := &fakeSkillService{}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodPost, "/api/v1/skills/skill_1/contents:read", strings.NewReader(`{"paths":["`+strings.Repeat("x", int(skillRepositoryContentsRequestMaxBytes))+`"],"expectedCommitSha":"abc123"}`))
	req.Header.Set("Content-Type", "application/json")
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, rec.Body.String())
	require.Empty(t, svc.readInput.Paths)
}

func TestSkillRepositoryBackupRoutesAreSkillScoped(t *testing.T) {
	svc := &fakeSkillService{}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)

	listReq := httptest.NewRequest(http.MethodGet, "/api/v1/skills/skill_1/backups?q=before&page=2&per_page=7", nil)
	listReq = listReq.WithContext(trusted.WithIdentity(listReq.Context(), trusted.Identity{Subject: "usr_1"}))
	listReq.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	listRec := httptest.NewRecorder()
	mux.ServeHTTP(listRec, listReq)
	require.Equal(t, http.StatusOK, listRec.Code, listRec.Body.String())
	require.Equal(t, "before", svc.backupQuery)
	require.Equal(t, 2, svc.backupPage)
	require.Equal(t, 7, svc.backupPerPage)
	require.Contains(t, listRec.Body.String(), `"tagName":"memory-20260708-010203"`)
	require.Contains(t, listRec.Body.String(), `"hasMore":true`)

	createReq := httptest.NewRequest(http.MethodPost, "/api/v1/skills/skill_1/backups", strings.NewReader(`{"name":"Before import"}`))
	createReq = createReq.WithContext(trusted.WithIdentity(createReq.Context(), trusted.Identity{Subject: "usr_1", PreferredUsername: "Lloyd User", Email: "lloyd@example.test"}))
	createReq.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	createReq.Header.Set("Content-Type", "application/json")
	createRec := httptest.NewRecorder()
	mux.ServeHTTP(createRec, createReq)
	require.Equal(t, http.StatusCreated, createRec.Code, createRec.Body.String())
	require.Equal(t, "Before import", svc.backupInput.Name)
	require.Equal(t, "usr_1", svc.backupInput.Actor.Subject)
	require.Equal(t, "lloyd-user", svc.backupInput.Actor.Login)

	restoreReq := httptest.NewRequest(http.MethodPost, "/api/v1/skills/skill_1/backups:restore", strings.NewReader(`{"tagName":"memory-20260708-010203"}`))
	restoreReq = restoreReq.WithContext(trusted.WithIdentity(restoreReq.Context(), trusted.Identity{Subject: "usr_1"}))
	restoreReq.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	restoreReq.Header.Set("Content-Type", "application/json")
	restoreRec := httptest.NewRecorder()
	mux.ServeHTTP(restoreRec, restoreReq)
	require.Equal(t, http.StatusOK, restoreRec.Code, restoreRec.Body.String())
	require.Equal(t, "memory-20260708-010203", svc.restoreTagName)
	require.Contains(t, restoreRec.Body.String(), `"commitSha":"abc123"`)
}

func TestSkillListPassesTemplateLocaleQuery(t *testing.T) {
	now := time.Date(2026, 6, 18, 1, 0, 0, 0, time.UTC)
	svc := &fakeSkillService{skill: store.Skill{ID: "skill_1", WorkspaceID: "ws_1", Slug: "reviewer", Name: "Reviewer", IsActive: true, CreatedAt: now, UpdatedAt: now}}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodGet, "/api/v1/skills?q=rev&page=2&per_page=5&template_locale=zh-CN&view=shared", nil)
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()

	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Equal(t, skillservice.SkillListOptions{
		Query:          "rev",
		Page:           2,
		PerPage:        5,
		TemplateLocale: "zh-CN",
		View:           "shared",
	}, svc.listOptions)
}

func TestSkillUpdateRoutePreservesOmittedFields(t *testing.T) {
	now := time.Date(2026, 6, 18, 1, 0, 0, 0, time.UTC)
	svc := &fakeSkillService{skill: store.Skill{ID: "skill_1", WorkspaceID: "ws_1", Slug: "reviewer", Name: "Reviewer", Description: "Keep me", IsActive: false, CreatedAt: now, UpdatedAt: now}}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodPatch, "/api/v1/skills/skill_1", strings.NewReader(`{"isActive":false}`))
	req.Header.Set("Content-Type", "application/json")
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Nil(t, svc.updateInput.Name)
	require.Nil(t, svc.updateInput.Slug)
	require.Nil(t, svc.updateInput.Description)
	require.NotNil(t, svc.updateInput.IsActive)
	require.False(t, *svc.updateInput.IsActive)
	require.Contains(t, rec.Body.String(), `"description":"Keep me"`)
}

func TestSkillUpdateRoutePassesVisibilityScope(t *testing.T) {
	now := time.Date(2026, 6, 18, 1, 0, 0, 0, time.UTC)
	svc := &fakeSkillService{skill: store.Skill{ID: "skill_1", WorkspaceID: "ws_1", Slug: "reviewer", Name: "Reviewer", VisibilityScope: "workspace", IsActive: true, CreatedAt: now, UpdatedAt: now}}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodPatch, "/api/v1/skills/skill_1", strings.NewReader(`{"visibilityScope":"workspace"}`))
	req.Header.Set("Content-Type", "application/json")
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.NotNil(t, svc.updateInput.VisibilityScope)
	require.Equal(t, "workspace", *svc.updateInput.VisibilityScope)
}

func TestAgentSkillAccessBatchEmptyRequestReturnsEmptyMap(t *testing.T) {
	svc := &fakeSkillService{}
	handler := NewSkillsHandler(svc)
	request, err := http.NewRequest(http.MethodPost, "/api/v1/skills/agents/access:batch", nil)
	require.NoError(t, err)
	request.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")

	response, err := handler.AgentAccessBatch(meshxapi.AuthenticatedJSON[dto.AgentSkillAccessBatchRequest]{
		AuthenticatedRequest: meshxapi.AuthenticatedRequest{Request: request, Identity: trusted.Identity{Subject: "usr_1"}},
		Body:                 dto.AgentSkillAccessBatchRequest{AgentIDs: nil},
	})
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, response.Status)
	body, ok := response.Body.(dto.AgentSkillAccessBatchResponse)
	require.True(t, ok)
	require.Empty(t, body.Agents)
	require.Empty(t, body.SyncToken)
	require.Nil(t, svc.batchInput)
}

func TestAgentSkillAccessBatchForwardsSyncToken(t *testing.T) {
	svc := &fakeSkillService{batchToken: "ags_rat_skill", batchExpiry: time.Date(2026, 6, 18, 2, 0, 0, 0, time.UTC)}
	handler := NewSkillsHandler(svc)
	request, err := http.NewRequest(http.MethodPost, "/api/v1/skills/agents/access:batch", nil)
	require.NoError(t, err)
	request.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")

	response, err := handler.AgentAccessBatch(meshxapi.AuthenticatedJSON[dto.AgentSkillAccessBatchRequest]{
		AuthenticatedRequest: meshxapi.AuthenticatedRequest{Request: request, Identity: trusted.Identity{Subject: "usr_1"}},
		Body:                 dto.AgentSkillAccessBatchRequest{AgentIDs: []string{"agt_1"}},
	})
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, response.Status)
	body, ok := response.Body.(dto.AgentSkillAccessBatchResponse)
	require.True(t, ok)
	require.Equal(t, "ags_rat_skill", body.SyncToken)
	require.NotEmpty(t, body.SyncTokenExpires)
	require.Equal(t, []string{"agt_1"}, svc.batchInput)
}

func TestAgentSkillAccessBatchPassesTemplateLocale(t *testing.T) {
	svc := &fakeSkillService{}
	handler := NewSkillsHandler(svc)
	request, err := http.NewRequest(http.MethodPost, "/api/v1/skills/agents/access:batch", nil)
	require.NoError(t, err)
	request.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")

	response, err := handler.AgentAccessBatch(meshxapi.AuthenticatedJSON[dto.AgentSkillAccessBatchRequest]{
		AuthenticatedRequest: meshxapi.AuthenticatedRequest{Request: request, Identity: trusted.Identity{Subject: "usr_1"}},
		Body:                 dto.AgentSkillAccessBatchRequest{AgentIDs: []string{"agt_1"}, TemplateLocale: "ja"},
	})

	require.NoError(t, err)
	require.Equal(t, http.StatusOK, response.Status)
	require.Equal(t, skillservice.AgentSkillAccessBatchOptions{
		AgentIDs:       []string{"agt_1"},
		TemplateLocale: "ja",
	}, svc.batchOptions)
}

func TestAgentSkillBindingsMapsSharedOwnershipErrors(t *testing.T) {
	svc := &fakeSkillService{bindingsErr: repos.ErrForbidden}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodGet, "/api/v1/skills/agents/bindings", nil)
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()

	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String())
	require.Contains(t, rec.Body.String(), `"error":"forbidden"`)
}

func TestAgentSkillBindingsPassesTemplateLocaleQuery(t *testing.T) {
	svc := &fakeSkillService{}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodGet, "/api/v1/skills/agents/bindings?template_locale=zh-CN", nil)
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()

	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Equal(t, "zh-CN", svc.bindingOptions.TemplateLocale)
}

type recordingRegistrar struct{ patterns []string }

func (r *recordingRegistrar) Handle(pattern string, _ http.Handler) {
	r.patterns = append(r.patterns, pattern)
}

func TestCreateSkillPassesRepositoryActor(t *testing.T) {
	now := time.Date(2026, 6, 18, 1, 0, 0, 0, time.UTC)
	svc := &fakeSkillService{skill: store.Skill{ID: "skill_1", WorkspaceID: "ws_1", Slug: "reviewer", Name: "Reviewer", SkillCount: 1, IsActive: true, CreatedAt: now, UpdatedAt: now}, repos: skillservice.SkillRepositories{Primary: &reposstore.ResourceRepository{ID: "rr_1", RepoRole: "primary", RepoOwner: "usr_1", RepoName: "reviewer.sk", RepoFullName: "usr_1/reviewer.sk", UpdatedAt: now}}}
	handler := NewSkillsHandler(svc)
	request, err := http.NewRequest(http.MethodPost, "/api/v1/skills", nil)
	require.NoError(t, err)
	request.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")

	response, err := handler.Create(meshxapi.AuthenticatedJSON[dto.SkillCreateRequest]{AuthenticatedRequest: meshxapi.AuthenticatedRequest{Request: request, Identity: trusted.Identity{Subject: "usr_1", PreferredUsername: "Lloyd User", Name: "Lloyd", Email: "lloyd@example.test", Groups: []string{"eng"}}}, Body: dto.SkillCreateRequest{Name: "Reviewer", VisibilityScope: "workspace", TemplateLocale: "zh-CN"}})
	require.NoError(t, err)
	require.Equal(t, http.StatusCreated, response.Status)
	require.Equal(t, "Reviewer", svc.createInput.Name)
	require.Equal(t, "workspace", svc.createInput.VisibilityScope)
	require.Equal(t, "zh-CN", svc.createInput.TemplateLocale)
	require.Equal(t, "usr_1", svc.createInput.Actor.Subject)
	require.Equal(t, "lloyd-user", svc.createInput.Actor.Login)
	body := response.Body.(dto.Skill)
	require.Equal(t, "reviewer.sk", body.Repositories.Primary.RepoName)
	require.Equal(t, 1, body.SkillCount)
}

func TestSkillRoutesServeJSONAndRejectReposPath(t *testing.T) {
	now := time.Date(2026, 6, 18, 1, 0, 0, 0, time.UTC)
	svc := &fakeSkillService{skill: store.Skill{ID: "skill_1", WorkspaceID: "ws_1", Slug: "reviewer", Name: "Reviewer", IsActive: true, CreatedAt: now, UpdatedAt: now}}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodPut, "/api/v1/skills/agents/agt_1/bindings/skill_1", strings.NewReader(`{"branch":"main"}`))
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Equal(t, "main", svc.bindInput.Branch)

	reposReq := httptest.NewRequest(http.MethodPost, "/api/v1/skills/repos", strings.NewReader(`{}`))
	reposReq = reposReq.WithContext(trusted.WithIdentity(reposReq.Context(), trusted.Identity{Subject: "usr_1"}))
	reposRec := httptest.NewRecorder()
	mux.ServeHTTP(reposRec, reposReq)
	require.NotEqual(t, http.StatusCreated, reposRec.Code)
}

func TestSkillPermissionRoutesPassGrantMutations(t *testing.T) {
	svc := &fakeSkillService{}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodPatch, "/api/v1/skills/skill_1/permissions/grants/grant_1", strings.NewReader(`{"subjectType":"user","subjectId":"usr_viewer","role":"manage"}`))
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_owner"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()

	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Equal(t, "skill_1", svc.permissionSkillID)
	require.Equal(t, "grant_1", svc.permissionGrantID)
	require.Equal(t, skillservice.SkillPermissionGrantMutation{GrantID: "grant_1", SubjectType: "user", SubjectID: "usr_viewer", Role: "manage"}, svc.permissionInput)
}

func TestSkillPermissionDeleteRoutePassesGrantID(t *testing.T) {
	svc := &fakeSkillService{}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodDelete, "/api/v1/skills/skill_1/permissions/grants/grant_1", nil)
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_owner"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()

	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
	require.Equal(t, "skill_1", svc.permissionSkillID)
	require.Equal(t, "grant_1", svc.permissionGrantID)
}

func TestSkillActivityRouteReturnsCursorPage(t *testing.T) {
	now := time.Date(2026, 6, 22, 10, 0, 0, 0, time.UTC)
	svc := &fakeSkillService{activityPage: activity.ResourceActivityPage{
		Items: []activity.ResourceActivityItem{{
			ID:          "act_1",
			WorkspaceID: "ws_1",
			AssetType:   "skill",
			AssetID:     "skill_1",
			AssetName:   "Reviewer",
			EventType:   "issue_opened",
			Actor:       activity.ResourceActivityActor{Type: "user", ID: "usr_1", Name: "Alice"},
			SummaryKey:  "skill.issue_opened",
			Details:     map[string]any{"number": float64(1)},
			Source:      "gitea",
			OccurredAt:  now,
		}},
		NextCursor: "cursor_2",
	}}
	mux := http.NewServeMux()
	RegisterRoutes(mux, NewSkillsHandler(svc), nil)
	req := httptest.NewRequest(http.MethodGet, "/api/v1/skills/activity?assetId=skill_1&eventType=issue_opened&actorId=usr_1&limit=25", nil)
	req = req.WithContext(trusted.WithIdentity(req.Context(), trusted.Identity{Subject: "usr_1"}))
	req.Header.Set(meshxapi.HeaderWorkspaceID, "ws_1")
	rec := httptest.NewRecorder()

	mux.ServeHTTP(rec, req)

	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Equal(t, activity.ResourceActivityListOptions{AssetID: "skill_1", EventType: "issue_opened", ActorID: "usr_1", Limit: 25}, svc.activityOpts)
	require.Contains(t, rec.Body.String(), `"nextCursor":"cursor_2"`)
	require.Contains(t, rec.Body.String(), `"summaryKey":"skill.issue_opened"`)
}
