package main

import (
	"bufio"
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/pingcap/agent-git-service/internal/sync/bulk"
)

const defaultGitHubAPI = "https://api.github.com"
const githubStacksAPIVersion = "2026-03-10"
const defaultShardItems = bulk.DefaultMaxItems
const defaultMaxShardBytes int64 = bulk.DefaultMaxBytes
const defaultMaxAssetBytes int64 = 100 * 1024 * 1024
const defaultFetchConcurrency = 8
const defaultUploadBulkShards = 25
const defaultUploadBulkBytes int64 = 16 * 1024 * 1024
const defaultApplyBatchLimit = 1

type snapshotManifest struct {
	SchemaVersion int                 `json:"schema_version"`
	Source        string              `json:"source"`
	SourceAPIURL  string              `json:"source_api_url"`
	SourceRepo    string              `json:"source_repo"`
	CreatedAt     time.Time           `json:"created_at"`
	Shards        []snapshotShard     `json:"shards"`
	AssetFiles    []snapshotAssetFile `json:"asset_files,omitempty"`
}

type snapshotShard struct {
	ObjectType string `json:"object_type"`
	Path       string `json:"path"`
	ItemCount  int    `json:"item_count"`
	ByteSize   int64  `json:"byte_size,omitempty"`
	Checksum   string `json:"checksum"`
}

// snapshotAssetFile describes a release asset whose bytes are stored beside
// the snapshot rather than embedded in an NDJSON payload. Path is always
// relative to the snapshot directory.
type snapshotAssetFile struct {
	SourceID string `json:"source_id"`
	Path     string `json:"path"`
	Size     int64  `json:"size"`
	Checksum string `json:"checksum"`
}

type batchItem struct {
	SourceID     string          `json:"source_id,omitempty"`
	SourceNodeID string          `json:"source_node_id,omitempty"`
	SourceNumber *int            `json:"source_number,omitempty"`
	SourceURL    string          `json:"source_url,omitempty"`
	Payload      json.RawMessage `json:"payload"`
}

type importBatchRequest struct {
	BatchID       string      `json:"batch_id"`
	ObjectType    string      `json:"object_type"`
	SchemaVersion int         `json:"schema_version"`
	Checksum      string      `json:"checksum"`
	Items         []batchItem `json:"items"`
}

type importBatchesRequest struct {
	Batches []importBatchRequest `json:"batches"`
}

type importApplyRequest struct {
	BatchLimit int  `json:"batch_limit,omitempty"`
	SkipVerify bool `json:"skip_verify,omitempty"`
}

type importApplyResponse struct {
	ImportID                  uint           `json:"import_id"`
	AppliedBatches            int            `json:"applied_batches"`
	AppliedItems              int            `json:"applied_items"`
	SkippedBatches            int            `json:"skipped_batches"`
	RemainingBatches          int            `json:"remaining_batches"`
	RemainingSupportedBatches int            `json:"remaining_supported_batches"`
	PendingByType             map[string]int `json:"pending_by_type"`
	FailedByType              map[string]int `json:"failed_by_type"`
}

type githubClient struct {
	apiURL string
	token  string
	http   *http.Client
}

// githubAPIError retains the response status so optional GitHub preview
// surfaces can be capability-gated without treating a disabled feature as a
// broken repository migration.
type githubAPIError struct {
	Operation  string
	StatusCode int
	Status     string
	Body       string
}

func (e *githubAPIError) Error() string {
	return fmt.Sprintf("GitHub %s failed: %s: %s", e.Operation, e.Status, strings.TrimSpace(e.Body))
}

func main() {
	if err := run(context.Background(), os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "ags-migrate:", err)
		os.Exit(1)
	}
}

func run(ctx context.Context, args []string) error {
	if len(args) < 2 {
		return usage()
	}
	switch args[0] {
	case "plan":
		return migrationPlanCommand(ctx, args[1:])
	case "github":
		switch args[1] {
		case "plan":
			return githubPlan(ctx, args[2:])
		case "snapshot":
			return githubSnapshot(ctx, args[2:])
		default:
			return usage()
		}
	case "ags":
		switch args[1] {
		case "create":
			return agsCreate(ctx, args[2:])
		case "mirror":
			return agsMirror(ctx, args[2:])
		case "upload":
			return agsUpload(ctx, args[2:])
		case "apply":
			return agsApply(ctx, args[2:])
		case "verify":
			return agsGet(ctx, args[2:], "verify")
		default:
			return usage()
		}
	default:
		return usage()
	}
}

func usage() error {
	return errors.New("usage: ags-migrate plan validate|preflight|run|report|identity-template ... OR ags-migrate github plan|snapshot ... OR ags-migrate ags create|mirror|upload|apply|verify ...")
}

func githubPlan(ctx context.Context, args []string) error {
	fs := flag.NewFlagSet("github plan", flag.ContinueOnError)
	repo := fs.String("repo", "", "GitHub repository owner/name")
	apiURL := fs.String("api", defaultGitHubAPI, "GitHub API base URL")
	tokenEnv := fs.String("token-env", "GITHUB_TOKEN", "environment variable containing GitHub token")
	if err := fs.Parse(args); err != nil {
		return err
	}
	owner, name, err := splitRepo(*repo)
	if err != nil {
		return err
	}
	client := newGitHubClient(*apiURL, os.Getenv(*tokenEnv))
	repoDoc, err := client.getJSON(ctx, "/repos/"+owner+"/"+name, nil)
	if err != nil {
		return err
	}
	prCount, _ := client.searchCount(ctx, "repo:"+owner+"/"+name+" type:pr")
	issueCount, _ := client.searchCount(ctx, "repo:"+owner+"/"+name+" type:issue")
	out := map[string]any{
		"source_repo":        owner + "/" + name,
		"github_api_url":     *apiURL,
		"repo":               repoDoc,
		"pull_request_count": prCount,
		"issue_count":        issueCount,
		"batch_strategy": []string{
			"git and wiki are mirrored with git",
			"collaboration data is snapshotted into sharded NDJSON",
			"snapshot shards are uploaded to AGS import batches",
		},
	}
	return writeJSON(os.Stdout, out)
}

func githubSnapshot(ctx context.Context, args []string) error {
	fs := flag.NewFlagSet("github snapshot", flag.ContinueOnError)
	repo := fs.String("repo", "", "GitHub repository owner/name")
	apiURL := fs.String("api", defaultGitHubAPI, "GitHub API base URL")
	tokenEnv := fs.String("token-env", "GITHUB_TOKEN", "environment variable containing GitHub token")
	outDir := fs.String("out", "", "snapshot output directory")
	objects := fs.String("objects", "core", "comma-separated object types, core, full, or all")
	shardSize := fs.Int("shard-size", defaultShardItems, "maximum items per snapshot shard")
	maxShardBytes := fs.Int64("max-shard-bytes", defaultMaxShardBytes, "maximum bytes per snapshot shard")
	fetchConcurrency := fs.Int("fetch-concurrency", defaultFetchConcurrency, "parallel GitHub list requests for nested objects")
	maxAssetBytes := fs.Int64("max-asset-bytes", defaultMaxAssetBytes, "maximum release asset bytes embedded in snapshot")
	if err := fs.Parse(args); err != nil {
		return err
	}
	owner, name, err := splitRepo(*repo)
	if err != nil {
		return err
	}
	if strings.TrimSpace(*outDir) == "" {
		return errors.New("--out is required")
	}
	if err := os.MkdirAll(*outDir, 0o755); err != nil {
		return err
	}

	client := newGitHubClient(*apiURL, os.Getenv(*tokenEnv))
	selectedObjects := expandSnapshotObjects(*objects)
	manifest := snapshotManifest{
		SchemaVersion: 1,
		Source:        "github",
		SourceAPIURL:  *apiURL,
		SourceRepo:    owner + "/" + name,
		CreatedAt:     time.Now().UTC(),
	}
	cache := snapshotSourceCache{}
	for _, objectType := range selectedObjects {
		if objectType == "release_asset" || objectType == "release_assets" {
			releases, err := ensureReleasePayloads(ctx, client, owner, name, cache.releases)
			if err != nil {
				return err
			}
			cache.releases = releases
			payloads, assetFiles, err := snapshotReleaseAssetsToFiles(ctx, client, releases, *maxAssetBytes, *outDir)
			if err != nil {
				return err
			}
			shards, err := writeSnapshotShards(*outDir, "release_assets", payloads, *shardSize, *maxShardBytes)
			if err != nil {
				return err
			}
			manifest.Shards = append(manifest.Shards, shards...)
			manifest.AssetFiles = append(manifest.AssetFiles, assetFiles...)
			continue
		}
		objectType, payloads, err := snapshotObjectPayloads(ctx, client, owner, name, objectType, &cache, *fetchConcurrency, *maxAssetBytes)
		if err != nil {
			return err
		}
		shards, err := writeSnapshotShards(*outDir, objectType, payloads, *shardSize, *maxShardBytes)
		if err != nil {
			return err
		}
		manifest.Shards = append(manifest.Shards, shards...)
	}
	return writeManifest(*outDir, manifest)
}

// snapshotSourceCache prevents a single snapshot pass from repeatedly listing
// Issues, pull requests, or releases for dependent object types.
type snapshotSourceCache struct {
	issues   []json.RawMessage
	pulls    []json.RawMessage
	releases []json.RawMessage
}

// snapshotAccessHandoff is a source access record, not a destination grant.
// It is stored as a reviewable handoff item because source membership must
// never silently become AGS repository access.
type snapshotAccessHandoff struct {
	Kind         string `json:"kind"`
	SourceID     string `json:"source_id"`
	SourceNodeID string `json:"source_node_id,omitempty"`
	SourceLogin  string `json:"source_login,omitempty"`
	GitHubID     int64  `json:"github_id,omitempty"`
	SourceURL    string `json:"source_url,omitempty"`
	Team         string `json:"team,omitempty"`
	TeamName     string `json:"team_name,omitempty"`
	Permission   string `json:"permission,omitempty"`
}

// snapshotObjectPayloads is shared by snapshot creation and source-fence
// verification so both operate on the same source representation. Release
// assets return metadata plus a content hash; githubSnapshot writes their bytes
// into separate files when it creates a full snapshot.
func snapshotObjectPayloads(ctx context.Context, client *githubClient, owner, name, objectType string, cache *snapshotSourceCache, fetchConcurrency int, maxAssetBytes int64) (string, []json.RawMessage, error) {
	switch objectType {
	case "label", "labels":
		payloads, err := client.listRaw(ctx, repoPath(owner, name, "labels"), nil)
		return "labels", payloads, err
	case "milestone", "milestones":
		payloads, err := client.listRaw(ctx, repoPath(owner, name, "milestones"), url.Values{"state": []string{"all"}, "sort": []string{"due_on"}, "direction": []string{"asc"}})
		return "milestones", payloads, err
	case "issue", "issues":
		issues, err := ensureIssuePayloads(ctx, client, owner, name, cache.issues)
		if err != nil {
			return "issues", nil, err
		}
		cache.issues = issues
		return "issues", filterIssuePayloads(issues), nil
	case "issue_comment", "issue_comments":
		payloads, err := snapshotIssueComments(ctx, client, owner, name)
		return "issue_comments", payloads, err
	case "pull_request", "pull_requests":
		pulls, err := ensurePullPayloads(ctx, client, owner, name, cache.pulls)
		if err != nil {
			return "pull_requests", nil, err
		}
		cache.pulls = pulls
		return "pull_requests", pulls, nil
	case "pull_request_stack", "pull_request_stacks":
		payloads, err := snapshotPullRequestStacks(ctx, client, owner, name)
		return "pull_request_stacks", payloads, err
	case "pull_request_review", "pull_request_reviews":
		pulls, err := ensurePullPayloads(ctx, client, owner, name, cache.pulls)
		if err != nil {
			return "pull_request_reviews", nil, err
		}
		cache.pulls = pulls
		payloads, err := snapshotPullReviews(ctx, client, owner, name, pulls, fetchConcurrency)
		return "pull_request_reviews", payloads, err
	case "pull_request_review_comment", "pull_request_review_comments":
		pulls, err := ensurePullPayloads(ctx, client, owner, name, cache.pulls)
		if err != nil {
			return "pull_request_review_comments", nil, err
		}
		cache.pulls = pulls
		payloads, err := snapshotPullReviewComments(ctx, client, owner, name, pulls)
		return "pull_request_review_comments", payloads, err
	case "release", "releases":
		releases, err := ensureReleasePayloads(ctx, client, owner, name, cache.releases)
		if err != nil {
			return "releases", nil, err
		}
		cache.releases = releases
		return "releases", releases, nil
	case "release_asset", "release_assets":
		releases, err := ensureReleasePayloads(ctx, client, owner, name, cache.releases)
		if err != nil {
			return "release_assets", nil, err
		}
		cache.releases = releases
		// Release binaries are written as separate snapshot files by
		// githubSnapshot. This helper intentionally returns only metadata plus a
		// hash so source-fence verification uses the same representation.
		payloads, err := fingerprintReleaseAssets(ctx, client, releases, maxAssetBytes)
		return "release_assets", payloads, err
	case "branch_protection", "branch_protections":
		payloads, err := snapshotBranchProtections(ctx, client, owner, name)
		return "branch_protections", payloads, err
	case "webhook", "webhooks":
		payloads, err := client.listRaw(ctx, repoPath(owner, name, "hooks"), nil)
		return "webhooks", payloads, err
	case "workflow", "workflows":
		payloads, err := snapshotWorkflowList(ctx, client, owner, name)
		return "workflows", payloads, err
	case "secret_name", "secret_names":
		payloads, err := snapshotSecretNames(ctx, client, owner, name)
		return "secret_names", payloads, err
	case "access_handoff", "access_handoffs":
		payloads, err := snapshotAccessHandoffs(ctx, client, owner, name)
		return "access_handoffs", payloads, err
	default:
		return "", nil, fmt.Errorf("unsupported snapshot object type %q", objectType)
	}
}

var allSnapshotObjectTypes = []string{
	"labels",
	"milestones",
	"issues",
	"issue_comments",
	"pull_requests",
	"pull_request_stacks",
	"pull_request_reviews",
	"pull_request_review_comments",
	"releases",
	"release_assets",
	"branch_protections",
	"webhooks",
	"workflows",
	"secret_names",
	"access_handoffs",
}

var coreSnapshotObjectTypes = []string{
	"labels",
	"milestones",
	"issues",
	"issue_comments",
	"pull_requests",
	"pull_request_stacks",
	"pull_request_reviews",
	"pull_request_review_comments",
	"releases",
	"branch_protections",
	"workflows",
}

func expandSnapshotObjects(raw string) []string {
	values := splitCSV(raw)
	if len(values) == 0 {
		return append([]string(nil), coreSnapshotObjectTypes...)
	}
	out := make([]string, 0, len(values))
	seen := map[string]bool{}
	for _, value := range values {
		if value == "core" {
			for _, objectType := range coreSnapshotObjectTypes {
				if !seen[objectType] {
					out = append(out, objectType)
					seen[objectType] = true
				}
			}
			continue
		}
		if value == "all" {
			for _, objectType := range allSnapshotObjectTypes {
				if !seen[objectType] {
					out = append(out, objectType)
					seen[objectType] = true
				}
			}
			continue
		}
		if value == "full" {
			for _, objectType := range allSnapshotObjectTypes {
				if !seen[objectType] {
					out = append(out, objectType)
					seen[objectType] = true
				}
			}
			continue
		}
		if !seen[value] {
			out = append(out, value)
			seen[value] = true
		}
	}
	return out
}

func snapshotAccessHandoffs(ctx context.Context, client *githubClient, owner, name string) ([]json.RawMessage, error) {
	collaborators, err := client.listRaw(ctx, repoPath(owner, name, "collaborators"), url.Values{"affiliation": []string{"all"}})
	if err != nil {
		return nil, fmt.Errorf("read repository collaborators: %w", err)
	}

	handoffs := make([]snapshotAccessHandoff, 0, len(collaborators))
	for _, raw := range collaborators {
		var collaborator struct {
			ID          int64           `json:"id"`
			NodeID      string          `json:"node_id"`
			Login       string          `json:"login"`
			HTMLURL     string          `json:"html_url"`
			Permissions map[string]bool `json:"permissions"`
			RoleName    string          `json:"role_name"`
		}
		if err := json.Unmarshal(raw, &collaborator); err != nil {
			return nil, fmt.Errorf("decode collaborator: %w", err)
		}
		if collaborator.ID <= 0 || strings.TrimSpace(collaborator.Login) == "" {
			return nil, errors.New("GitHub collaborator has no stable ID or login")
		}
		handoffs = append(handoffs, snapshotAccessHandoff{
			Kind: "collaborator", SourceID: collaborator.Login, SourceNodeID: collaborator.NodeID,
			SourceLogin: collaborator.Login, GitHubID: collaborator.ID, SourceURL: collaborator.HTMLURL,
			Permission: githubCollaboratorPermission(collaborator.Permissions, collaborator.RoleName),
		})
	}

	teams, err := client.listRaw(ctx, repoPath(owner, name, "teams"), nil)
	if err != nil {
		return nil, fmt.Errorf("read repository teams: %w", err)
	}
	for _, raw := range teams {
		var team struct {
			ID         int64  `json:"id"`
			NodeID     string `json:"node_id"`
			Name       string `json:"name"`
			Slug       string `json:"slug"`
			HTMLURL    string `json:"html_url"`
			Permission string `json:"permission"`
		}
		if err := json.Unmarshal(raw, &team); err != nil {
			return nil, fmt.Errorf("decode repository team: %w", err)
		}
		if team.ID <= 0 || strings.TrimSpace(team.Slug) == "" {
			return nil, errors.New("GitHub repository team has no stable ID or slug")
		}
		permission := normalizeSnapshotAccessPermission(team.Permission)
		handoffs = append(handoffs, snapshotAccessHandoff{
			Kind: "team", SourceID: strconv.FormatInt(team.ID, 10), SourceNodeID: team.NodeID,
			SourceURL: team.HTMLURL, Team: team.Slug, TeamName: firstString(team.Name, team.Slug), Permission: permission,
		})

		members, membersErr := client.listRaw(ctx, "/orgs/"+url.PathEscape(owner)+"/teams/"+url.PathEscape(team.Slug)+"/members", nil)
		if membersErr != nil {
			return nil, fmt.Errorf("read team %q members: %w", team.Slug, membersErr)
		}
		for _, memberRaw := range members {
			var member struct {
				ID      int64  `json:"id"`
				NodeID  string `json:"node_id"`
				Login   string `json:"login"`
				HTMLURL string `json:"html_url"`
			}
			if err := json.Unmarshal(memberRaw, &member); err != nil {
				return nil, fmt.Errorf("decode team member: %w", err)
			}
			if member.ID <= 0 || strings.TrimSpace(member.Login) == "" {
				return nil, errors.New("GitHub team member has no stable ID or login")
			}
			handoffs = append(handoffs, snapshotAccessHandoff{
				Kind: "team_member", SourceID: team.Slug + ":" + member.Login, SourceNodeID: member.NodeID,
				SourceLogin: member.Login, GitHubID: member.ID, SourceURL: member.HTMLURL,
				Team: team.Slug, TeamName: firstString(team.Name, team.Slug), Permission: permission,
			})
		}
	}

	sort.Slice(handoffs, func(i, j int) bool {
		if handoffs[i].Kind != handoffs[j].Kind {
			return handoffs[i].Kind < handoffs[j].Kind
		}
		return handoffs[i].SourceID < handoffs[j].SourceID
	})
	payloads := make([]json.RawMessage, 0, len(handoffs))
	for _, handoff := range handoffs {
		payload, err := json.Marshal(handoff)
		if err != nil {
			return nil, err
		}
		payloads = append(payloads, payload)
	}
	return payloads, nil
}

func githubCollaboratorPermission(permissions map[string]bool, roleName string) string {
	if permissions["admin"] || strings.EqualFold(roleName, "admin") {
		return "admin"
	}
	if permissions["maintain"] || permissions["push"] || strings.EqualFold(roleName, "maintain") || strings.EqualFold(roleName, "write") {
		return "write"
	}
	return "read"
}

func normalizeSnapshotAccessPermission(permission string) string {
	switch strings.ToLower(strings.TrimSpace(permission)) {
	case "admin":
		return "admin"
	case "maintain", "push", "write":
		return "write"
	default:
		return "read"
	}
}

func ensureIssuePayloads(ctx context.Context, client *githubClient, owner, name string, cached []json.RawMessage) ([]json.RawMessage, error) {
	if cached != nil {
		return cached, nil
	}
	return client.listRaw(ctx, repoPath(owner, name, "issues"), url.Values{"state": []string{"all"}, "sort": []string{"created"}, "direction": []string{"asc"}})
}

func ensurePullPayloads(ctx context.Context, client *githubClient, owner, name string, cached []json.RawMessage) ([]json.RawMessage, error) {
	if cached != nil {
		return cached, nil
	}
	return client.listRaw(ctx, repoPath(owner, name, "pulls"), url.Values{"state": []string{"all"}, "sort": []string{"created"}, "direction": []string{"asc"}})
}

// snapshotPullRequestStacks captures the documented Stack resource, whose
// pull_requests list is already ordered bottom-to-top. A 404 is the documented
// capability result when GitHub Stacks is not enabled for a repository, not a
// failed source read. Other errors must stop the snapshot so a cutover cannot
// silently discard readable Stack relationships.
func snapshotPullRequestStacks(ctx context.Context, client *githubClient, owner, name string) ([]json.RawMessage, error) {
	payloads, err := listGitHubPullRequestStacks(ctx, client, owner, name)
	if isGitHubStacksUnavailable(err) {
		return nil, nil
	}
	return payloads, err
}

func listGitHubPullRequestStacks(ctx context.Context, client *githubClient, owner, name string) ([]json.RawMessage, error) {
	return client.listRawWithAPIVersion(ctx, repoPath(owner, name, "stacks"), nil, githubStacksAPIVersion)
}

func ensureReleasePayloads(ctx context.Context, client *githubClient, owner, name string, cached []json.RawMessage) ([]json.RawMessage, error) {
	if cached != nil {
		return cached, nil
	}
	return client.listRaw(ctx, repoPath(owner, name, "releases"), nil)
}

func filterIssuePayloads(issues []json.RawMessage) []json.RawMessage {
	out := make([]json.RawMessage, 0, len(issues))
	for _, issue := range issues {
		if rawHasKey(issue, "pull_request") {
			continue
		}
		out = append(out, issue)
	}
	return out
}

func snapshotIssueComments(ctx context.Context, client *githubClient, owner, name string) ([]json.RawMessage, error) {
	comments, err := client.listRaw(ctx, repoPath(owner, name, "issues/comments"), url.Values{"sort": []string{"created"}, "direction": []string{"asc"}})
	if err != nil {
		return nil, err
	}
	out := make([]json.RawMessage, 0, len(comments))
	for _, comment := range comments {
		number := rawURLLastInt(comment, "issue_url")
		if number == 0 {
			out = append(out, comment)
			continue
		}
		enriched, err := enrichRaw(comment, map[string]any{"issue_number": number})
		if err != nil {
			return nil, err
		}
		out = append(out, enriched)
	}
	return out, nil
}

func snapshotPullReviews(ctx context.Context, client *githubClient, owner, name string, pulls []json.RawMessage, concurrency int) ([]json.RawMessage, error) {
	return snapshotNestedByNumberConcurrent(ctx, pulls, concurrency, func(ctx context.Context, number int) ([]json.RawMessage, error) {
		reviews, err := client.listRaw(ctx, repoPath(owner, name, "pulls/"+strconv.Itoa(number)+"/reviews"), nil)
		if err != nil {
			return nil, err
		}
		out := make([]json.RawMessage, 0, len(reviews))
		for _, review := range reviews {
			enriched, err := enrichRaw(review, map[string]any{"pull_request_number": number})
			if err != nil {
				return nil, err
			}
			out = append(out, enriched)
		}
		return out, nil
	})
}

func snapshotPullReviewComments(ctx context.Context, client *githubClient, owner, name string, pulls []json.RawMessage) ([]json.RawMessage, error) {
	comments, err := client.listRaw(ctx, repoPath(owner, name, "pulls/comments"), url.Values{"sort": []string{"created"}, "direction": []string{"asc"}})
	if err != nil {
		return nil, err
	}
	knownPulls := rawNumberSet(pulls)
	out := make([]json.RawMessage, 0, len(comments))
	for _, comment := range comments {
		number := rawURLLastInt(comment, "pull_request_url")
		if number == 0 || !knownPulls[number] {
			continue
		}
		enriched, err := enrichRaw(comment, map[string]any{"pull_request_number": number})
		if err != nil {
			return nil, err
		}
		out = append(out, enriched)
	}
	return out, nil
}

func rawNumberSet(payloads []json.RawMessage) map[int]bool {
	out := make(map[int]bool, len(payloads))
	for _, payload := range payloads {
		number := rawInt(payload, "number")
		if number != 0 {
			out[number] = true
		}
	}
	return out
}

func snapshotNestedByNumberConcurrent(ctx context.Context, payloads []json.RawMessage, concurrency int, fetch func(context.Context, int) ([]json.RawMessage, error)) ([]json.RawMessage, error) {
	type job struct {
		index  int
		number int
	}
	if concurrency <= 0 {
		concurrency = defaultFetchConcurrency
	}
	var jobsList []job
	for _, payload := range payloads {
		number := rawInt(payload, "number")
		if number == 0 {
			continue
		}
		jobsList = append(jobsList, job{index: len(jobsList), number: number})
	}
	if len(jobsList) == 0 {
		return nil, nil
	}
	chunks := make([][]json.RawMessage, len(jobsList))
	err := bulk.ForEach(ctx, len(jobsList), concurrency, func(workerCtx context.Context, index int) error {
		items, err := fetch(workerCtx, jobsList[index].number)
		if err != nil {
			return err
		}
		chunks[index] = items
		return nil
	})
	if err != nil {
		return nil, err
	}
	var out []json.RawMessage
	for _, chunk := range chunks {
		out = append(out, chunk...)
	}
	return out, nil
}

func snapshotReleaseAssetsToFiles(ctx context.Context, client *githubClient, releases []json.RawMessage, maxAssetBytes int64, snapshotDir string) ([]json.RawMessage, []snapshotAssetFile, error) {
	var out []json.RawMessage
	var files []snapshotAssetFile
	seenSourceIDs := map[string]struct{}{}
	for _, releaseRaw := range releases {
		var release struct {
			ID        int64             `json:"id"`
			TagName   string            `json:"tag_name"`
			AssetsURL string            `json:"assets_url"`
			Assets    []json.RawMessage `json:"assets"`
		}
		if err := json.Unmarshal(releaseRaw, &release); err != nil {
			return nil, nil, err
		}
		assets, err := releaseAssetsForSnapshot(ctx, client, release.AssetsURL, release.Assets)
		if err != nil {
			return nil, nil, err
		}
		for _, asset := range assets {
			enriched, file, err := snapshotReleaseAssetToFile(ctx, client, asset, release.ID, release.TagName, maxAssetBytes, snapshotDir)
			if err != nil {
				return nil, nil, err
			}
			if _, exists := seenSourceIDs[file.SourceID]; exists {
				return nil, nil, fmt.Errorf("duplicate release asset source_id %q", file.SourceID)
			}
			seenSourceIDs[file.SourceID] = struct{}{}
			out = append(out, enriched)
			files = append(files, file)
		}
	}
	return out, files, nil
}

// fingerprintReleaseAssets uses the same paginated source asset list as a
// snapshot, but retains only the checksum. This gives the source fence content
// integrity without keeping every binary a second time.
func fingerprintReleaseAssets(ctx context.Context, client *githubClient, releases []json.RawMessage, maxAssetBytes int64) ([]json.RawMessage, error) {
	var out []json.RawMessage
	for _, releaseRaw := range releases {
		var release struct {
			ID        int64             `json:"id"`
			TagName   string            `json:"tag_name"`
			AssetsURL string            `json:"assets_url"`
			Assets    []json.RawMessage `json:"assets"`
		}
		if err := json.Unmarshal(releaseRaw, &release); err != nil {
			return nil, err
		}
		assets, err := releaseAssetsForSnapshot(ctx, client, release.AssetsURL, release.Assets)
		if err != nil {
			return nil, err
		}
		for _, asset := range assets {
			fingerprint, err := fingerprintReleaseAsset(ctx, client, asset, release.ID, release.TagName, maxAssetBytes)
			if err != nil {
				return nil, err
			}
			out = append(out, fingerprint)
		}
	}
	return out, nil
}

// releaseAssetsForSnapshot always prefers the dedicated assets endpoint. The
// embedded release.assets array is not a pagination contract, while assets_url
// is and may be needed for releases with many files.
func releaseAssetsForSnapshot(ctx context.Context, client *githubClient, assetsURL string, embedded []json.RawMessage) ([]json.RawMessage, error) {
	if strings.TrimSpace(assetsURL) == "" {
		return embedded, nil
	}
	return client.listRaw(ctx, assetsURL, nil)
}

func snapshotReleaseAssetToFile(ctx context.Context, client *githubClient, raw json.RawMessage, releaseID int64, releaseTag string, maxAssetBytes int64, snapshotDir string) (json.RawMessage, snapshotAssetFile, error) {
	var asset struct {
		ID   int64  `json:"id"`
		URL  string `json:"url"`
		Size int64  `json:"size"`
	}
	if err := json.Unmarshal(raw, &asset); err != nil {
		return nil, snapshotAssetFile{}, err
	}
	if maxAssetBytes <= 0 {
		return nil, snapshotAssetFile{}, errors.New("max_asset_bytes must be greater than zero when release assets are selected")
	}
	if asset.Size > maxAssetBytes {
		return nil, snapshotAssetFile{}, fmt.Errorf("release asset exceeds max_asset_bytes (%d > %d)", asset.Size, maxAssetBytes)
	}
	if asset.URL == "" {
		return nil, snapshotAssetFile{}, errors.New("release asset has no API URL for content download")
	}
	if asset.ID <= 0 {
		return nil, snapshotAssetFile{}, errors.New("release asset has no source id")
	}
	relativePath := filepath.ToSlash(filepath.Join("release-assets", strconv.FormatInt(asset.ID, 10)))
	path, err := snapshotAssetFilePath(snapshotDir, relativePath)
	if err != nil {
		return nil, snapshotAssetFile{}, err
	}
	size, checksum, err := client.downloadAssetToFile(ctx, asset.URL, path, maxAssetBytes)
	if err != nil {
		return nil, snapshotAssetFile{}, err
	}
	if size != asset.Size {
		_ = os.Remove(path)
		return nil, snapshotAssetFile{}, fmt.Errorf("release asset size changed while downloading (%d bytes received, expected %d)", size, asset.Size)
	}
	enriched, err := enrichRaw(raw, map[string]any{
		"release_id":       releaseID,
		"release_tag_name": releaseTag,
		"content_sha256":   checksum,
		"content_size":     size,
	})
	if err != nil {
		return nil, snapshotAssetFile{}, err
	}
	return enriched, snapshotAssetFile{
		SourceID: strconv.FormatInt(asset.ID, 10),
		Path:     relativePath,
		Size:     size,
		Checksum: checksum,
	}, nil
}

func fingerprintReleaseAsset(ctx context.Context, client *githubClient, raw json.RawMessage, releaseID int64, releaseTag string, maxAssetBytes int64) (json.RawMessage, error) {
	var asset struct {
		URL  string `json:"url"`
		Size int64  `json:"size"`
	}
	if err := json.Unmarshal(raw, &asset); err != nil {
		return nil, err
	}
	if maxAssetBytes <= 0 {
		return nil, errors.New("max_asset_bytes must be greater than zero when release assets are selected")
	}
	if asset.Size > maxAssetBytes {
		return nil, fmt.Errorf("release asset exceeds max_asset_bytes (%d > %d)", asset.Size, maxAssetBytes)
	}
	if asset.URL == "" {
		return nil, errors.New("release asset has no API URL for content download")
	}
	size, checksum, err := client.hashAssetContent(ctx, asset.URL, maxAssetBytes)
	if err != nil {
		return nil, err
	}
	if size != asset.Size {
		return nil, fmt.Errorf("release asset size changed while hashing (%d bytes received, expected %d)", size, asset.Size)
	}
	return enrichRaw(raw, map[string]any{
		"release_id":       releaseID,
		"release_tag_name": releaseTag,
		"content_sha256":   checksum,
		"content_size":     size,
	})
}

func snapshotBranchProtections(ctx context.Context, client *githubClient, owner, name string) ([]json.RawMessage, error) {
	branches, err := client.listRaw(ctx, repoPath(owner, name, "branches"), url.Values{"protected": []string{"true"}})
	if err != nil {
		return nil, err
	}
	out := make([]json.RawMessage, 0, len(branches))
	for _, branchRaw := range branches {
		var branch struct {
			Name string `json:"name"`
			URL  string `json:"url"`
		}
		if err := json.Unmarshal(branchRaw, &branch); err != nil {
			return nil, err
		}
		if branch.Name == "" {
			continue
		}
		protection, err := client.getRaw(ctx, repoPath(owner, name, "branches/"+url.PathEscape(branch.Name)+"/protection"), nil)
		if err != nil {
			return nil, err
		}
		enriched, err := enrichRaw(protection, map[string]any{
			"branch_name": branch.Name,
			"url":         branch.URL,
		})
		if err != nil {
			return nil, err
		}
		out = append(out, enriched)
	}
	return out, nil
}

func snapshotWorkflowList(ctx context.Context, client *githubClient, owner, name string) ([]json.RawMessage, error) {
	raw, err := client.getRaw(ctx, repoPath(owner, name, "actions/workflows"), nil)
	if err != nil {
		return nil, err
	}
	var payload struct {
		Workflows []json.RawMessage `json:"workflows"`
	}
	if err := json.Unmarshal(raw, &payload); err != nil {
		return nil, err
	}
	return payload.Workflows, nil
}

func snapshotSecretNames(ctx context.Context, client *githubClient, owner, name string) ([]json.RawMessage, error) {
	raw, err := client.getRaw(ctx, repoPath(owner, name, "actions/secrets"), nil)
	if err != nil {
		return nil, err
	}
	var payload struct {
		Secrets []json.RawMessage `json:"secrets"`
	}
	if err := json.Unmarshal(raw, &payload); err != nil {
		return nil, err
	}
	return payload.Secrets, nil
}

func agsCreate(ctx context.Context, args []string) error {
	fs := flag.NewFlagSet("ags create", flag.ContinueOnError)
	target := fs.String("target", "", "AGS base URL")
	sourceRepo := fs.String("source-repo", "", "GitHub source repository owner/name")
	targetRepo := fs.String("target-repo", "", "AGS target repository owner/name")
	targetMode := fs.String("target-mode", "create", "target mode: create or existing_empty")
	sourceAPI := fs.String("source-api", defaultGitHubAPI, "GitHub API base URL recorded on the import job")
	sourceHTML := fs.String("source-html", "https://github.com", "GitHub HTML base URL recorded on the import job")
	tokenEnv := fs.String("token-env", "AGS_TOKEN", "environment variable containing AGS token")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *target == "" || *sourceRepo == "" || *targetRepo == "" {
		return errors.New("--target, --source-repo, and --target-repo are required")
	}
	sourceOwner, sourceName, err := splitRepo(*sourceRepo)
	if err != nil {
		return err
	}
	targetOwner, targetName, err := splitRepo(*targetRepo)
	if err != nil {
		return err
	}
	token := strings.TrimSpace(os.Getenv(*tokenEnv))
	if token == "" {
		return fmt.Errorf("%s is empty", *tokenEnv)
	}
	req := map[string]any{
		"source_owner":    sourceOwner,
		"source_repo":     sourceName,
		"source_api_url":  *sourceAPI,
		"source_html_url": strings.TrimRight(*sourceHTML, "/") + "/" + sourceOwner + "/" + sourceName,
		"target_owner":    targetOwner,
		"target_repo":     targetName,
		"target_mode":     *targetMode,
		"options": map[string]any{
			"mirror_git":                false,
			"import_labels":             false,
			"import_milestones":         false,
			"import_issues":             false,
			"import_issue_comments":     false,
			"import_pull_requests":      false,
			"import_pr_reviews":         false,
			"import_pr_review_comments": false,
			"import_releases":           false,
			"import_release_assets":     false,
			"import_branch_protection":  false,
			"import_webhooks":           false,
			"import_secret_names":       false,
			"import_actions_metadata":   true,
			"max_release_asset_bytes":   defaultMaxAssetBytes,
			"import_wiki":               false,
			"import_collaborators":      false,
			"import_teams":              false,
		},
	}
	endpoint := strings.TrimRight(*target, "/") + "/api/ext/v1/imports/github"
	return postJSON(ctx, endpoint, token, req, os.Stdout)
}

func agsMirror(ctx context.Context, args []string) error {
	fs := flag.NewFlagSet("ags mirror", flag.ContinueOnError)
	sourceRepo := fs.String("source-repo", "", "GitHub source repository owner/name")
	sourceAPI := fs.String("source-api", defaultGitHubAPI, "GitHub API base URL")
	sourceTokenEnv := fs.String("source-token-env", "GITHUB_TOKEN", "environment variable containing the source GitHub token")
	target := fs.String("target", "", "AGS base or API URL")
	targetRepo := fs.String("target-repo", "", "AGS target repository owner/name")
	targetTokenEnv := fs.String("target-token-env", "AGS_TOKEN", "environment variable containing the AGS token")
	includeWiki := fs.Bool("wiki", false, "also mirror the GitHub Wiki repository")
	if err := fs.Parse(args); err != nil {
		return err
	}
	sourceOwner, sourceName, err := splitRepo(*sourceRepo)
	if err != nil {
		return err
	}
	targetOwner, targetName, err := splitRepo(*targetRepo)
	if err != nil {
		return err
	}
	sourceToken, err := requiredEnv(*sourceTokenEnv)
	if err != nil {
		return err
	}
	targetToken, err := requiredEnv(*targetTokenEnv)
	if err != nil {
		return err
	}
	sourceRemote, err := githubGitRemote(*sourceAPI, sourceOwner, sourceName)
	if err != nil {
		return err
	}
	targetRemote, err := agsGitRemote(*target, targetOwner, targetName)
	if err != nil {
		return err
	}
	if err := mirrorGitRepository(ctx, sourceRemote, sourceToken, targetRemote, targetToken, ""); err != nil {
		return err
	}
	if *includeWiki {
		if err := mirrorGitRepository(ctx, strings.TrimSuffix(sourceRemote, ".git")+".wiki.git", sourceToken, strings.TrimSuffix(targetRemote, ".git")+".wiki.git", targetToken, ""); err != nil {
			return fmt.Errorf("mirror wiki: %w", err)
		}
	}
	return writeJSON(os.Stdout, map[string]any{"source": *sourceRepo, "target": *targetRepo, "wiki": *includeWiki, "status": "mirrored"})
}

func agsUpload(ctx context.Context, args []string) error {
	fs := flag.NewFlagSet("ags upload", flag.ContinueOnError)
	target := fs.String("target", "", "AGS base URL")
	importID := fs.Uint("import-id", 0, "AGS import job id")
	tokenEnv := fs.String("token-env", "AGS_TOKEN", "environment variable containing AGS token")
	snapshotDir := fs.String("snapshot", "", "snapshot directory")
	bulkSize := fs.Int("bulk-size", defaultUploadBulkShards, "maximum snapshot shards per bulk upload request; set to 1 to use single-batch uploads")
	bulkMaxBytes := fs.Int64("bulk-max-bytes", defaultUploadBulkBytes, "approximate maximum item payload bytes per bulk upload request")
	sendManifestChecksum := fs.Bool("send-manifest-checksum", false, "send the snapshot manifest checksum instead of letting AGS compute the payload checksum")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *target == "" || *importID == 0 || *snapshotDir == "" {
		return errors.New("--target, --import-id, and --snapshot are required")
	}
	token := strings.TrimSpace(os.Getenv(*tokenEnv))
	if token == "" {
		return fmt.Errorf("%s is empty", *tokenEnv)
	}
	manifest, err := readManifest(*snapshotDir)
	if err != nil {
		return err
	}
	baseEndpoint := strings.TrimRight(*target, "/") + "/api/ext/v1/imports/" + strconv.FormatUint(uint64(*importID), 10)
	if err := uploadSnapshotAssetFiles(ctx, baseEndpoint, token, *snapshotDir, manifest); err != nil {
		return err
	}
	var requests []importBatchRequest
	for _, shard := range manifest.Shards {
		items, err := readBatchItems(filepath.Join(*snapshotDir, shard.Path))
		if err != nil {
			return err
		}
		if len(items) == 0 {
			continue
		}
		checksum := ""
		if *sendManifestChecksum {
			checksum = shard.Checksum
		}
		req := importBatchRequest{
			BatchID:       strings.TrimSuffix(filepath.Base(shard.Path), filepath.Ext(shard.Path)),
			ObjectType:    shard.ObjectType,
			SchemaVersion: manifest.SchemaVersion,
			Checksum:      checksum,
			Items:         items,
		}
		requests = append(requests, req)
	}
	if len(requests) == 0 {
		return nil
	}

	if *bulkSize <= 1 {
		return uploadSingleBatches(ctx, baseEndpoint, token, requests, os.Stdout)
	}
	chunks := chunkUploadBatches(requests, *bulkSize, *bulkMaxBytes)
	for i, chunk := range chunks {
		err := postJSON(ctx, baseEndpoint+"/batches/bulk", token, importBatchesRequest{Batches: chunk}, os.Stdout)
		if err == nil {
			continue
		}
		var statusErr *httpStatusError
		if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) && i == 0 {
			fmt.Fprintln(os.Stderr, "bulk upload endpoint unavailable; falling back to single-batch uploads")
			return uploadSingleBatches(ctx, baseEndpoint, token, requests, os.Stdout)
		}
		return err
	}
	return nil
}

// uploadSnapshotAssetFiles sends release binaries before their metadata batch.
// The server can therefore reject an apply that references a missing or
// checksum-mismatched binary without accepting a giant Base64 JSON payload.
func uploadSnapshotAssetFiles(ctx context.Context, baseEndpoint, token, snapshotDir string, manifest snapshotManifest) error {
	for _, asset := range manifest.AssetFiles {
		if strings.TrimSpace(asset.SourceID) == "" {
			return errors.New("snapshot asset source_id is required")
		}
		path, err := snapshotAssetFilePath(snapshotDir, asset.Path)
		if err != nil {
			return fmt.Errorf("invalid snapshot asset path for %q: %w", asset.SourceID, err)
		}
		if err := verifySnapshotAssetFile(path, asset.Size, asset.Checksum); err != nil {
			return fmt.Errorf("verify snapshot asset %q: %w", asset.SourceID, err)
		}
		file, err := os.Open(path)
		if err != nil {
			return err
		}
		endpoint := baseEndpoint + "/release-assets/" + url.PathEscape(asset.SourceID)
		req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, file)
		if err != nil {
			file.Close()
			return err
		}
		req.ContentLength = asset.Size
		req.Header.Set("Authorization", "Bearer "+token)
		req.Header.Set("Content-Type", "application/octet-stream")
		req.Header.Set("X-Import-Asset-Size", strconv.FormatInt(asset.Size, 10))
		req.Header.Set("X-Import-Asset-Checksum", asset.Checksum)
		resp, err := http.DefaultClient.Do(req)
		file.Close()
		if err != nil {
			return err
		}
		body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
		resp.Body.Close()
		if readErr != nil {
			return readErr
		}
		if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
			return &httpStatusError{Endpoint: endpoint, StatusCode: resp.StatusCode, Status: resp.Status, Body: string(body)}
		}
	}
	return nil
}

func chunkUploadBatches(requests []importBatchRequest, maxCount int, maxBytes int64) [][]importBatchRequest {
	if maxCount <= 0 {
		maxCount = defaultUploadBulkShards
	}
	var chunks [][]importBatchRequest
	var current []importBatchRequest
	var currentBytes int64
	flush := func() {
		if len(current) == 0 {
			return
		}
		chunks = append(chunks, current)
		current = nil
		currentBytes = 0
	}
	for _, req := range requests {
		reqBytes := importBatchRequestPayloadBytes(req)
		overCount := len(current) >= maxCount
		overBytes := maxBytes > 0 && len(current) > 0 && currentBytes+reqBytes > maxBytes
		if overCount || overBytes {
			flush()
		}
		current = append(current, req)
		currentBytes += reqBytes
	}
	flush()
	return chunks
}

func importBatchRequestPayloadBytes(req importBatchRequest) int64 {
	var total int64
	for _, item := range req.Items {
		total += int64(len(item.Payload))
		total += int64(len(item.SourceID) + len(item.SourceNodeID) + len(item.SourceURL) + 64)
	}
	return total + int64(len(req.BatchID)+len(req.ObjectType)+len(req.Checksum)+128)
}

func uploadSingleBatches(ctx context.Context, baseEndpoint, token string, requests []importBatchRequest, w io.Writer) error {
	for _, req := range requests {
		if err := postJSON(ctx, baseEndpoint+"/batches", token, req, w); err != nil {
			return err
		}
	}
	return nil
}

func agsApply(ctx context.Context, args []string) error {
	fs := flag.NewFlagSet("ags apply", flag.ContinueOnError)
	target := fs.String("target", "", "AGS base URL")
	importID := fs.Uint("import-id", 0, "AGS import job id")
	tokenEnv := fs.String("token-env", "AGS_TOKEN", "environment variable containing AGS token")
	batchLimit := fs.Int("batch-limit", defaultApplyBatchLimit, "maximum supported staged batches to apply per request; 0 applies all in one request")
	maxRounds := fs.Int("max-rounds", 0, "maximum apply requests to send; 0 runs until no supported staged batches remain")
	once := fs.Bool("once", false, "send one apply request and return")
	verifyEachRound := fs.Bool("verify-each-round", false, "ask AGS to run full import verification after every apply request")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *target == "" || *importID == 0 {
		return errors.New("--target and --import-id are required")
	}
	if *batchLimit < 0 {
		return errors.New("--batch-limit must be greater than or equal to 0")
	}
	if *maxRounds < 0 {
		return errors.New("--max-rounds must be greater than or equal to 0")
	}
	token := strings.TrimSpace(os.Getenv(*tokenEnv))
	if token == "" {
		return fmt.Errorf("%s is empty", *tokenEnv)
	}

	endpoint := strings.TrimRight(*target, "/") + "/api/ext/v1/imports/" + strconv.FormatUint(uint64(*importID), 10) + "/apply"
	var lastBody []byte
	for round := 1; ; round++ {
		body, err := postJSONResponse(ctx, endpoint, token, importApplyRequest{
			BatchLimit: *batchLimit,
			SkipVerify: !*verifyEachRound,
		})
		if err != nil {
			return err
		}
		lastBody = body

		var report importApplyResponse
		if err := json.Unmarshal(body, &report); err != nil {
			return fmt.Errorf("decode apply response: %w", err)
		}
		fmt.Fprintf(os.Stderr, "apply round %d: applied_batches=%d applied_items=%d remaining_supported_batches=%d\n",
			round, report.AppliedBatches, report.AppliedItems, report.RemainingSupportedBatches)

		if *once || *batchLimit == 0 || report.RemainingSupportedBatches == 0 {
			break
		}
		if *maxRounds > 0 && round >= *maxRounds {
			break
		}
		if report.AppliedBatches == 0 && report.SkippedBatches == 0 {
			return fmt.Errorf("apply made no progress with %d supported staged batches remaining", report.RemainingSupportedBatches)
		}
	}
	return writeRawJSONLine(os.Stdout, lastBody)
}

func agsPost(ctx context.Context, args []string, action string) error {
	target, importID, token, err := parseAGSActionFlags(args, "ags "+action)
	if err != nil {
		return err
	}
	endpoint := strings.TrimRight(target, "/") + "/api/ext/v1/imports/" + strconv.FormatUint(uint64(importID), 10) + "/" + action
	return postJSON(ctx, endpoint, token, map[string]any{}, os.Stdout)
}

func agsGet(ctx context.Context, args []string, action string) error {
	target, importID, token, err := parseAGSActionFlags(args, "ags "+action)
	if err != nil {
		return err
	}
	endpoint := strings.TrimRight(target, "/") + "/api/ext/v1/imports/" + strconv.FormatUint(uint64(importID), 10) + "/" + action
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+token)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return fmt.Errorf("AGS %s failed: %s: %s", action, resp.Status, strings.TrimSpace(string(body)))
	}
	_, err = io.Copy(os.Stdout, resp.Body)
	return err
}

func parseAGSActionFlags(args []string, name string) (string, uint, string, error) {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	target := fs.String("target", "", "AGS base URL")
	importID := fs.Uint("import-id", 0, "AGS import job id")
	tokenEnv := fs.String("token-env", "AGS_TOKEN", "environment variable containing AGS token")
	if err := fs.Parse(args); err != nil {
		return "", 0, "", err
	}
	if *target == "" || *importID == 0 {
		return "", 0, "", errors.New("--target and --import-id are required")
	}
	token := strings.TrimSpace(os.Getenv(*tokenEnv))
	if token == "" {
		return "", 0, "", fmt.Errorf("%s is empty", *tokenEnv)
	}
	return *target, *importID, token, nil
}

func repoPath(owner, name, suffix string) string {
	return "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/" + strings.TrimPrefix(suffix, "/")
}

func rawHasKey(raw json.RawMessage, key string) bool {
	var meta map[string]json.RawMessage
	if err := json.Unmarshal(raw, &meta); err != nil {
		return false
	}
	_, ok := meta[key]
	return ok
}

func rawInt(raw json.RawMessage, key string) int {
	var meta map[string]any
	if err := json.Unmarshal(raw, &meta); err != nil {
		return 0
	}
	if value := jsonIntPtr(meta, key); value != nil {
		return *value
	}
	return 0
}

func rawURLLastInt(raw json.RawMessage, key string) int {
	var meta map[string]any
	if err := json.Unmarshal(raw, &meta); err != nil {
		return 0
	}
	return lastURLPathInt(jsonString(meta, key))
}

func lastURLPathInt(raw string) int {
	raw = strings.TrimRight(strings.TrimSpace(raw), "/")
	if raw == "" {
		return 0
	}
	idx := strings.LastIndex(raw, "/")
	if idx >= 0 {
		raw = raw[idx+1:]
	}
	n, err := strconv.Atoi(raw)
	if err != nil {
		return 0
	}
	return n
}

func enrichRaw(raw json.RawMessage, extra map[string]any) (json.RawMessage, error) {
	meta := map[string]any{}
	if err := json.Unmarshal(raw, &meta); err != nil {
		return nil, err
	}
	for key, value := range extra {
		meta[key] = value
	}
	return json.Marshal(meta)
}

func newGitHubClient(apiURL, token string) *githubClient {
	return &githubClient{
		apiURL: strings.TrimRight(apiURL, "/"),
		token:  strings.TrimSpace(token),
		http:   http.DefaultClient,
	}
}

func (c *githubClient) getJSON(ctx context.Context, path string, q url.Values) (map[string]any, error) {
	body, err := c.getBytes(ctx, path, q)
	if err != nil {
		return nil, err
	}
	var out map[string]any
	if err := json.Unmarshal(body, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func (c *githubClient) getRaw(ctx context.Context, path string, q url.Values) (json.RawMessage, error) {
	body, err := c.getBytes(ctx, path, q)
	if err != nil {
		return nil, err
	}
	return append(json.RawMessage(nil), body...), nil
}

func (c *githubClient) searchCount(ctx context.Context, query string) (int, error) {
	out, err := c.getJSON(ctx, "/search/issues", url.Values{"q": []string{query}, "per_page": []string{"1"}})
	if err != nil {
		return 0, err
	}
	n, _ := out["total_count"].(float64)
	return int(n), nil
}

func (c *githubClient) listRaw(ctx context.Context, path string, q url.Values) ([]json.RawMessage, error) {
	return c.listRawWithAPIVersion(ctx, path, q, "")
}

func (c *githubClient) listRawWithAPIVersion(ctx context.Context, path string, q url.Values, apiVersion string) ([]json.RawMessage, error) {
	var out []json.RawMessage
	next := c.endpoint(path, withPerPage(q))
	for next != "" {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
		if err != nil {
			return nil, err
		}
		c.setHeaders(req)
		if apiVersion != "" {
			req.Header.Set("X-GitHub-Api-Version", apiVersion)
		}
		resp, err := c.http.Do(req)
		if err != nil {
			return nil, err
		}
		body, readErr := io.ReadAll(resp.Body)
		resp.Body.Close()
		if readErr != nil {
			return nil, readErr
		}
		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
			return nil, &githubAPIError{Operation: "list " + path, StatusCode: resp.StatusCode, Status: resp.Status, Body: string(body)}
		}
		var page []json.RawMessage
		if err := json.Unmarshal(body, &page); err != nil {
			return nil, err
		}
		out = append(out, page...)
		next = parseNextLink(resp.Header.Get("Link"))
	}
	return out, nil
}

func (c *githubClient) openAsset(ctx context.Context, assetURL string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint(assetURL, nil), nil)
	if err != nil {
		return nil, err
	}
	c.setHeaders(req)
	req.Header.Set("Accept", "application/octet-stream")
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		resp.Body.Close()
		return nil, fmt.Errorf("GitHub asset download failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
	}
	return resp, nil
}

func normalizeAssetLimit(maxBytes int64) (int64, error) {
	limit := maxBytes
	if limit <= 0 {
		limit = defaultMaxAssetBytes
	}
	if limit <= 0 {
		return 0, errors.New("max_asset_bytes must be greater than zero")
	}
	return limit, nil
}

func (c *githubClient) hashAssetContent(ctx context.Context, assetURL string, maxBytes int64) (int64, string, error) {
	limit, err := normalizeAssetLimit(maxBytes)
	if err != nil {
		return 0, "", err
	}
	resp, err := c.openAsset(ctx, assetURL)
	if err != nil {
		return 0, "", err
	}
	defer resp.Body.Close()
	hash := sha256.New()
	written, err := io.Copy(hash, io.LimitReader(resp.Body, limit+1))
	if err != nil {
		return 0, "", err
	}
	if written > limit {
		return 0, "", fmt.Errorf("GitHub asset exceeds max_asset_bytes %d", limit)
	}
	return written, "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
}

func (c *githubClient) downloadAssetToFile(ctx context.Context, assetURL, destination string, maxBytes int64) (int64, string, error) {
	limit, err := normalizeAssetLimit(maxBytes)
	if err != nil {
		return 0, "", err
	}
	resp, err := c.openAsset(ctx, assetURL)
	if err != nil {
		return 0, "", err
	}
	defer resp.Body.Close()
	if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
		return 0, "", err
	}
	temp, err := os.CreateTemp(filepath.Dir(destination), ".release-asset-*")
	if err != nil {
		return 0, "", err
	}
	tempPath := temp.Name()
	defer os.Remove(tempPath)
	hash := sha256.New()
	written, copyErr := io.Copy(io.MultiWriter(temp, hash), io.LimitReader(resp.Body, limit+1))
	closeErr := temp.Close()
	if copyErr != nil {
		return 0, "", copyErr
	}
	if closeErr != nil {
		return 0, "", closeErr
	}
	if written > limit {
		return 0, "", fmt.Errorf("GitHub asset exceeds max_asset_bytes %d", limit)
	}
	if err := os.Rename(tempPath, destination); err != nil {
		return 0, "", err
	}
	return written, "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
}

func (c *githubClient) getBytes(ctx context.Context, path string, q url.Values) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint(path, q), nil)
	if err != nil {
		return nil, err
	}
	c.setHeaders(req)
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, &githubAPIError{Operation: "GET " + path, StatusCode: resp.StatusCode, Status: resp.Status, Body: string(body)}
	}
	return body, nil
}

func isGitHubStacksUnavailable(err error) bool {
	var apiErr *githubAPIError
	return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
}

func (c *githubClient) endpoint(path string, q url.Values) string {
	u := path
	if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
		u = c.apiURL + path
	}
	if len(q) > 0 {
		separator := "?"
		if strings.Contains(u, "?") {
			separator = "&"
		}
		u += separator + q.Encode()
	}
	return u
}

func (c *githubClient) setHeaders(req *http.Request) {
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
	if c.token != "" {
		req.Header.Set("Authorization", "Bearer "+c.token)
	}
}

func withPerPage(q url.Values) url.Values {
	out := url.Values{}
	for key, values := range q {
		out[key] = append([]string(nil), values...)
	}
	if out.Get("per_page") == "" {
		out.Set("per_page", "100")
	}
	return out
}

func writeSnapshotShards(dir, objectType string, payloads []json.RawMessage, shardSize int, maxShardBytes int64) ([]snapshotShard, error) {
	if len(payloads) == 0 {
		return nil, nil
	}
	prepared := make([]json.RawMessage, 0, len(payloads))
	for _, payload := range payloads {
		trimmed := bytes.TrimSpace(payload)
		if len(trimmed) != 0 {
			prepared = append(prepared, append(json.RawMessage(nil), trimmed...))
		}
	}
	chunks, err := bulk.Shard(prepared, bulk.Limits{MaxItems: shardSize, MaxBytes: maxShardBytes}, func(payload json.RawMessage) int64 { return int64(len(payload) + 1) })
	if err != nil {
		return nil, err
	}
	shards := make([]snapshotShard, 0, len(chunks))
	for index, chunk := range chunks {
		shard, err := writeSnapshotShard(dir, objectType, index+1, chunk)
		if err != nil {
			return nil, err
		}
		shards = append(shards, shard)
	}
	return shards, nil
}

func writeSnapshotShard(dir, objectType string, shardIndex int, payloads []json.RawMessage) (snapshotShard, error) {
	name := fmt.Sprintf("%s-%06d.ndjson", objectType, shardIndex)
	path := filepath.Join(dir, name)
	file, err := os.Create(path)
	if err != nil {
		return snapshotShard{}, err
	}
	defer file.Close()
	h := sha256.New()
	countWritten := 0
	var byteSize int64
	for _, payload := range payloads {
		trimmed := bytes.TrimSpace(payload)
		if len(trimmed) == 0 {
			continue
		}
		if _, err := file.Write(trimmed); err != nil {
			return snapshotShard{}, err
		}
		if _, err := file.Write([]byte{'\n'}); err != nil {
			return snapshotShard{}, err
		}
		h.Write(trimmed)
		h.Write([]byte{'\n'})
		byteSize += int64(len(trimmed) + 1)
		countWritten++
	}
	return snapshotShard{
		ObjectType: objectType,
		Path:       name,
		ItemCount:  countWritten,
		ByteSize:   byteSize,
		Checksum:   "sha256:" + hex.EncodeToString(h.Sum(nil)),
	}, nil
}

func readBatchItems(path string) ([]batchItem, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()
	var out []batchItem
	reader := bufio.NewReaderSize(file, 256*1024)
	for {
		line, readErr := reader.ReadBytes('\n')
		payload := bytes.TrimSpace(line)
		if len(payload) == 0 {
			if errors.Is(readErr, io.EOF) {
				break
			}
			if readErr != nil {
				return nil, readErr
			}
			continue
		}
		meta := map[string]any{}
		_ = json.Unmarshal(payload, &meta)
		out = append(out, batchItem{
			SourceID:     firstString(jsonString(meta, "source_id"), jsonString(meta, "id"), jsonString(meta, "name"), jsonString(meta, "branch_name"), jsonString(meta, "path")),
			SourceNodeID: firstString(jsonString(meta, "source_node_id"), jsonString(meta, "node_id")),
			SourceNumber: firstIntPtr(jsonIntPtr(meta, "number"), jsonIntPtr(meta, "issue_number"), jsonIntPtr(meta, "pull_request_number"), jsonIntPtr(meta, "source_number")),
			SourceURL:    firstString(jsonString(meta, "source_url"), jsonString(meta, "html_url"), jsonString(meta, "browser_download_url"), jsonString(meta, "url")),
			Payload:      append(json.RawMessage(nil), payload...),
		})
		if errors.Is(readErr, io.EOF) {
			break
		}
		if readErr != nil {
			return nil, readErr
		}
	}
	return out, nil
}

func snapshotAssetFilePath(snapshotDir, relativePath string) (string, error) {
	clean := filepath.Clean(strings.TrimSpace(relativePath))
	if clean == "." || clean == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
		return "", errors.New("path must be relative to the snapshot directory")
	}
	return filepath.Join(snapshotDir, clean), nil
}

func verifySnapshotAssetFile(path string, expectedSize int64, expectedChecksum string) error {
	if expectedSize < 0 {
		return errors.New("asset size must be non-negative")
	}
	checksum := strings.TrimSpace(expectedChecksum)
	if !strings.HasPrefix(strings.ToLower(checksum), "sha256:") || len(checksum) != len("sha256:")+sha256.Size*2 {
		return errors.New("asset checksum must be a SHA-256 digest")
	}
	file, err := os.Open(path)
	if err != nil {
		return err
	}
	defer file.Close()
	info, err := file.Stat()
	if err != nil {
		return err
	}
	if info.Size() != expectedSize {
		return fmt.Errorf("size %d does not match expected %d", info.Size(), expectedSize)
	}
	hash := sha256.New()
	if _, err := io.Copy(hash, file); err != nil {
		return err
	}
	got := "sha256:" + hex.EncodeToString(hash.Sum(nil))
	if !strings.EqualFold(got, checksum) {
		return errors.New("checksum mismatch")
	}
	return nil
}

func writeManifest(dir string, manifest snapshotManifest) error {
	data, err := json.MarshalIndent(manifest, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(dir, "manifest.json"), append(data, '\n'), 0o644)
}

func readManifest(dir string) (snapshotManifest, error) {
	data, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
	if err != nil {
		return snapshotManifest{}, err
	}
	var manifest snapshotManifest
	if err := json.Unmarshal(data, &manifest); err != nil {
		return snapshotManifest{}, err
	}
	return manifest, nil
}

type httpStatusError struct {
	Endpoint   string
	StatusCode int
	Status     string
	Body       string
}

func (e *httpStatusError) Error() string {
	return fmt.Sprintf("POST %s failed: %s: %s", e.Endpoint, e.Status, strings.TrimSpace(e.Body))
}

func postJSON(ctx context.Context, endpoint, token string, value any, w io.Writer) error {
	body, err := postJSONResponse(ctx, endpoint, token, value)
	if err != nil {
		return err
	}
	return writeRawJSONLine(w, body)
}

func postJSONResponse(ctx context.Context, endpoint, token string, value any) ([]byte, error) {
	return sendJSONResponse(ctx, http.MethodPost, endpoint, token, value)
}

func patchJSONResponse(ctx context.Context, endpoint, token string, value any) ([]byte, error) {
	return sendJSONResponse(ctx, http.MethodPatch, endpoint, token, value)
}

func sendJSONResponse(ctx context.Context, method, endpoint, token string, value any) ([]byte, error) {
	body, err := json.Marshal(value)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	data, readErr := io.ReadAll(resp.Body)
	if readErr != nil {
		return nil, readErr
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, &httpStatusError{
			Endpoint:   endpoint,
			StatusCode: resp.StatusCode,
			Status:     resp.Status,
			Body:       string(data),
		}
	}
	return data, nil
}

func writeJSON(w io.Writer, value any) error {
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(value)
}

func writeRawJSONLine(w io.Writer, body []byte) error {
	if _, err := w.Write(body); err != nil {
		return err
	}
	if len(body) == 0 || body[len(body)-1] == '\n' {
		return nil
	}
	_, err := w.Write([]byte("\n"))
	return err
}

func splitRepo(repo string) (string, string, error) {
	parts := strings.Split(strings.TrimSpace(repo), "/")
	if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
		return "", "", errors.New("--repo must be owner/name")
	}
	return parts[0], parts[1], nil
}

func splitCSV(raw string) []string {
	var out []string
	for _, value := range strings.Split(raw, ",") {
		value = strings.ToLower(strings.TrimSpace(value))
		if value != "" {
			out = append(out, value)
		}
	}
	return out
}

func parseNextLink(header string) string {
	for _, part := range strings.Split(header, ",") {
		section := strings.Split(strings.TrimSpace(part), ";")
		if len(section) < 2 {
			continue
		}
		if strings.TrimSpace(section[1]) != `rel="next"` {
			continue
		}
		return strings.Trim(section[0], "<> ")
	}
	return ""
}

func jsonString(meta map[string]any, key string) string {
	value, ok := meta[key]
	if !ok || value == nil {
		return ""
	}
	switch v := value.(type) {
	case string:
		return v
	case float64:
		if v == float64(int64(v)) {
			return strconv.FormatInt(int64(v), 10)
		}
		return strconv.FormatFloat(v, 'f', -1, 64)
	default:
		return fmt.Sprint(v)
	}
}

func jsonIntPtr(meta map[string]any, key string) *int {
	value, ok := meta[key]
	if !ok || value == nil {
		return nil
	}
	switch v := value.(type) {
	case float64:
		n := int(v)
		return &n
	case int:
		return &v
	default:
		return nil
	}
}

func firstIntPtr(values ...*int) *int {
	for _, value := range values {
		if value != nil {
			return value
		}
	}
	return nil
}

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