package ags

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/gitstore"
	"github.com/pingcap/agent-git-service/internal/service"
	reposync "github.com/pingcap/agent-git-service/internal/sync"
	"github.com/pingcap/agent-git-service/internal/sync/canonical"
	"github.com/pingcap/agent-git-service/internal/sync/gittransport"
)

type Factory struct {
	Service        *service.Service
	MaxConcurrency int
}

func (f *Factory) Open(ctx context.Context, endpoint reposync.Endpoint) (reposync.Provider, error) {
	if f.Service == nil {
		return nil, fmt.Errorf("AGS sync provider requires service")
	}
	if endpoint.Provider != service.SyncProviderAGS || endpoint.AGSRepositoryID == 0 {
		return nil, fmt.Errorf("AGS provider cannot open endpoint %q", endpoint.Provider)
	}
	var repository db.Repository
	if err := f.Service.DBForCtx(ctx).First(&repository, "id = ?", endpoint.AGSRepositoryID).Error; err != nil {
		return nil, err
	}
	if endpoint.RepositoryID != strconv.FormatUint(uint64(repository.ID), 10) {
		return nil, fmt.Errorf("AGS repository immutable ID changed")
	}
	git, err := f.Service.GitForCtx(ctx)
	if err != nil {
		return nil, err
	}
	endpoint.RepositoryFullName = repository.FullName
	return &Provider{endpoint: endpoint, service: f.Service, repository: repository, git: git, inventoryByIdentity: make(map[string]reposync.InventoryObject)}, nil
}

type Provider struct {
	endpoint               reposync.Endpoint
	service                *service.Service
	repository             db.Repository
	git                    *gitstore.Store
	mu                     sync.RWMutex
	inventoryByIdentity    map[string]reposync.InventoryObject
	bootstrapRefTypes      map[string]bool
	linkMetadataByIdentity map[string]json.RawMessage
}

func (p *Provider) Endpoint() reposync.Endpoint { return p.endpoint }
func (p *Provider) Close() error                { return nil }
func (p *Provider) Visibility() string {
	visibility := strings.ToLower(strings.TrimSpace(p.repository.Visibility))
	if visibility == "" {
		if p.repository.Private {
			return "private"
		}
		return "public"
	}
	return visibility
}

func (p *Provider) SetObjectLinks(links []reposync.LinkView, sourceSide bool) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.linkMetadataByIdentity = make(map[string]json.RawMessage, len(links))
	for _, link := range links {
		identity := link.TargetIdentity
		if sourceSide {
			identity = link.SourceIdentity
		}
		if identity != "" {
			p.linkMetadataByIdentity[identityKey(link.ObjectType, identity)] = append(json.RawMessage(nil), link.Metadata...)
		}
	}
}

func (p *Provider) GitRemote(ctx context.Context, wiki bool) (reposync.GitRemote, error) {
	fullName := p.repository.FullName
	if wiki {
		fullName += ".wiki"
	}
	path, err := p.git.GetRepoPath(ctx, fullName)
	if err != nil {
		return reposync.GitRemote{}, err
	}
	return reposync.GitRemote{URL: path}, nil
}

func (p *Provider) Inventory(ctx context.Context) ([]reposync.InventoryObject, string, error) {
	collectors := []func(context.Context) ([]reposync.InventoryObject, error){
		p.inventoryGitRefs, p.inventoryLFSObjects, p.inventoryWikiRefs, p.inventoryLabels, p.inventoryMilestones,
		p.inventoryIssues, p.inventoryIssueComments, p.inventoryPullRequests,
		p.inventoryPullRequestReviews, p.inventoryReviewRequests, p.inventoryPullRequestReviewComments,
		p.inventoryReleases,
	}
	all := make([]reposync.InventoryObject, 0)
	for _, collector := range collectors {
		objects, err := collector(ctx)
		if err != nil {
			return nil, "", err
		}
		all = append(all, objects...)
	}
	sort.Slice(all, func(i, j int) bool {
		if all[i].ObjectType != all[j].ObjectType {
			return all[i].ObjectType < all[j].ObjectType
		}
		return all[i].Identity < all[j].Identity
	})
	digest := sha256.New()
	p.mu.Lock()
	p.inventoryByIdentity = make(map[string]reposync.InventoryObject, len(all))
	for _, object := range all {
		_, _ = fmt.Fprintf(digest, "%s\x00%s\x00%s\x00", object.ObjectType, object.Identity, object.ContentHash)
		p.inventoryByIdentity[identityKey(object.ObjectType, object.Identity)] = object
	}
	p.mu.Unlock()
	return all, hex.EncodeToString(digest.Sum(nil)), nil
}

func (p *Provider) InventoryIndex(ctx context.Context) ([]reposync.InventoryObject, string, error) {
	objects, digest, err := p.Inventory(ctx)
	if err != nil {
		return nil, "", err
	}
	for index := range objects {
		objects[index].Payload = nil
		objects[index].PayloadBytes = 0
	}
	return objects, digest, nil
}

func (p *Provider) IsEmpty(ctx context.Context) (bool, error) {
	// HEAD is an O(1) proof that an AGS repository is not empty. Avoid a full
	// ls-remote scan merely to reject the bootstrap path for an established
	// repository; large repositories can otherwise spend minutes here before
	// the first source inventory even begins. Repositories without HEAD still
	// fall through to the complete refs check, so tag-only and unusual default
	// branch layouts remain correct.
	if !p.git.IsEmpty(ctx, p.repository.FullName) {
		return false, nil
	}
	refs, err := p.inventoryGitRefs(ctx)
	if err != nil || len(refs) != 0 {
		return false, err
	}
	wiki, err := p.inventoryWikiRefs(ctx)
	if err != nil || len(wiki) != 0 {
		return false, err
	}
	database := p.service.DBForCtx(ctx)
	// AGS creates a default label set for a new repository. Those labels are
	// provider defaults, not business data, and must not disable bulk bootstrap.
	direct := []any{&db.Milestone{}, &db.Issue{}, &db.IssueComment{}, &db.PullRequest{}, &db.Release{}}
	for _, model := range direct {
		var count int64
		if err := database.Model(model).Where("repository_id = ?", p.repository.ID).Limit(1).Count(&count).Error; err != nil {
			return false, err
		}
		if count != 0 {
			return false, nil
		}
	}
	// Reviews, review requests/comments and release assets cannot belong to a
	// repository without their PR/release parent. Reactions likewise require
	// an Issue, PR, or comment, all checked above.
	return true, nil
}

func (p *Provider) Read(ctx context.Context, objectType, identity string) (reposync.InventoryObject, error) {
	if err := p.refreshType(ctx, objectType); err != nil {
		return reposync.InventoryObject{}, err
	}
	if object, ok := p.cached(objectType, identity); ok {
		return object, nil
	}
	return reposync.InventoryObject{}, reposync.ErrObjectNotFound
}

func (p *Provider) ReadBatch(ctx context.Context, objectType string, identities []string) (map[string]reposync.InventoryObject, error) {
	if err := p.refreshType(ctx, objectType); err != nil {
		return nil, err
	}
	result := make(map[string]reposync.InventoryObject, len(identities))
	for _, identity := range identities {
		if object, ok := p.cached(objectType, identity); ok {
			result[identity] = object
		}
	}
	return result, nil
}

func (p *Provider) Recover(ctx context.Context, item reposync.PlannedItem) (reposync.TargetState, error) {
	if item.ObjectType == canonical.TypeLFSObject && item.Action != reposync.ActionDelete {
		states, err := p.recoverLFSBatch(ctx, []reposync.PlannedItem{item})
		return states[reposync.ExecutionKey(item)], err
	}
	if err := p.refreshType(ctx, item.ObjectType); err != nil {
		return reposync.TargetState{}, err
	}
	return p.recoverCached(item)
}

func (p *Provider) RecoverBatch(ctx context.Context, items []reposync.PlannedItem) (map[string]reposync.TargetState, error) {
	result := make(map[string]reposync.TargetState, len(items))
	if len(items) == 0 {
		return result, nil
	}
	if items[0].ObjectType == canonical.TypeLFSObject {
		return p.recoverLFSBatch(ctx, items)
	}
	if err := p.refreshType(ctx, items[0].ObjectType); err != nil {
		return nil, err
	}
	for _, item := range items {
		state, err := p.recoverCached(item)
		if err != nil {
			return nil, err
		}
		result[reposync.ExecutionKey(item)] = state
	}
	return result, nil
}

func (p *Provider) recoverLFSBatch(ctx context.Context, items []reposync.PlannedItem) (map[string]reposync.TargetState, error) {
	result := make(map[string]reposync.TargetState, len(items))
	objects := make(map[string]int64)
	type lfsItem struct {
		oid  string
		size int64
	}
	decoded := make(map[string]lfsItem, len(items))
	for _, item := range items {
		if item.Action == reposync.ActionDelete {
			result[reposync.ExecutionKey(item)] = reposync.TargetState{Exists: false}
			continue
		}
		record, err := canonical.Decode(item.Payload)
		if err != nil {
			return nil, err
		}
		oid := canonical.StringField(record.Fields, "oid")
		size := int64(canonical.IntField(record.Fields, "size"))
		objects[oid] = size
		decoded[reposync.ExecutionKey(item)] = lfsItem{oid: oid, size: size}
	}
	if len(objects) == 0 {
		return result, nil
	}
	remote, err := p.GitRemote(ctx, false)
	if err != nil {
		return nil, err
	}
	verified, err := gittransport.VerifyLFSBatch(ctx, remote, objects)
	if err != nil {
		return nil, err
	}
	for _, item := range items {
		value, ok := decoded[reposync.ExecutionKey(item)]
		if !ok || !verified[value.oid] {
			if ok {
				result[reposync.ExecutionKey(item)] = reposync.TargetState{Exists: false}
			}
			continue
		}
		result[reposync.ExecutionKey(item)] = reposync.TargetState{Exists: true, Managed: true, Identity: value.oid, Revision: value.oid + ":" + strconv.FormatInt(value.size, 10), Hash: item.DesiredHash}
	}
	return result, nil
}

func (p *Provider) recoverCached(item reposync.PlannedItem) (reposync.TargetState, error) {
	if item.TargetIdentity != "" {
		if object, ok := p.cached(item.ObjectType, item.TargetIdentity); ok {
			return canonical.TargetStateFromInventory(object, true), nil
		}
	}
	source, err := canonical.Decode(item.Payload)
	if err != nil {
		return reposync.TargetState{}, err
	}
	p.mu.RLock()
	defer p.mu.RUnlock()
	var candidate *reposync.InventoryObject
	for _, object := range p.inventoryByIdentity {
		if object.ObjectType != item.ObjectType {
			continue
		}
		record, decodeErr := canonical.Decode(object.Payload)
		if decodeErr != nil {
			continue
		}
		if source.UniqueKey == "" || record.UniqueKey != source.UniqueKey {
			continue
		}
		if candidate != nil {
			return reposync.TargetState{}, fmt.Errorf("multiple AGS recovery candidates for %s", item.ObjectKey)
		}
		copy := object
		candidate = &copy
	}
	if candidate == nil {
		return reposync.TargetState{Exists: false}, nil
	}
	return canonical.TargetStateFromInventory(*candidate, false), nil
}

func (p *Provider) inventoryGitRefs(ctx context.Context) ([]reposync.InventoryObject, error) {
	return p.inventoryRefs(ctx, p.repository.FullName, canonical.TypeGitRef)
}

func (p *Provider) inventoryLFSObjects(ctx context.Context) ([]reposync.InventoryObject, error) {
	remote, err := p.GitRemote(ctx, false)
	if err != nil {
		return nil, err
	}
	return gittransport.InventoryLFS(ctx, remote)
}

func (p *Provider) inventoryWikiRefs(ctx context.Context) ([]reposync.InventoryObject, error) {
	wikiName := p.repository.FullName + ".wiki"
	if !p.git.Exists(ctx, wikiName) {
		return nil, nil
	}
	return p.inventoryRefs(ctx, wikiName, canonical.TypeWikiRef)
}

func (p *Provider) inventoryRefs(ctx context.Context, fullName, objectType string) ([]reposync.InventoryObject, error) {
	refs, err := p.git.ListRefsWithPrefix(ctx, fullName, "")
	if err != nil {
		return nil, err
	}
	objects := make([]reposync.InventoryObject, 0, len(refs))
	for _, ref := range refs {
		if !strings.HasPrefix(ref.Ref, "refs/heads/") && !strings.HasPrefix(ref.Ref, "refs/tags/") {
			continue
		}
		record := canonical.NewRecord(objectType, ref.Ref, objectType+":"+ref.Ref, map[string]any{"ref": ref.Ref, "sha": ref.SHA})
		record.UniqueKey = ref.Ref
		object, err := record.Inventory(ref.SHA, "native")
		if err != nil {
			return nil, err
		}
		objects = append(objects, object)
	}
	return objects, nil
}

func (p *Provider) inventoryLabels(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.Label
	if err := p.service.DBForCtx(ctx).Where("repository_id = ?", p.repository.ID).Find(&rows).Error; err != nil {
		return nil, err
	}
	return records(rows, func(row db.Label) (canonical.Record, string, string) {
		id := uintString(row.ID)
		record := canonical.NewRecord(canonical.TypeLabel, id, "label:"+id, map[string]any{"_database_id": row.ID, "name": row.Name, "color": row.Color, "description": row.Description})
		record.UniqueKey = strings.ToLower(row.Name)
		return record, row.UpdatedAt.UTC().Format(timeLayout), "native"
	})
}

func (p *Provider) inventoryMilestones(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.Milestone
	if err := p.service.DBForCtx(ctx).Preload("Creator").Where("repository_id = ?", p.repository.ID).Find(&rows).Error; err != nil {
		return nil, err
	}
	return records(rows, func(row db.Milestone) (canonical.Record, string, string) {
		id := uintString(row.ID)
		record := canonical.NewRecord(canonical.TypeMilestone, id, "milestone:"+id, map[string]any{"_database_id": row.ID, "_number": row.Number, "title": row.Title, "description": row.Description, "state": row.State, "due_on": timeString(row.DueOn)})
		record.UniqueKey = strings.ToLower(row.Title)
		setAGSProvenance(&record, row.Creator.Login, row.CreatedAt, p.endpoint, "milestones/"+strconv.Itoa(row.Number))
		return record, row.UpdatedAt.UTC().Format(timeLayout), "native"
	})
}

func (p *Provider) inventoryIssues(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.Issue
	if err := p.service.DBForCtx(ctx).Preload("Author").Preload("Labels").Preload("Milestone").Where("repository_id = ?", p.repository.ID).Find(&rows).Error; err != nil {
		return nil, err
	}
	return records(rows, func(row db.Issue) (canonical.Record, string, string) {
		id := uintString(row.ID)
		labels := make([]string, 0, len(row.Labels))
		for _, label := range row.Labels {
			labels = append(labels, label.Name)
		}
		sort.Strings(labels)
		milestone := ""
		if row.Milestone != nil {
			milestone = row.Milestone.Title
		}
		record := canonical.NewRecord(canonical.TypeIssue, id, "issue:"+id, map[string]any{"_database_id": row.ID, "_number": row.Number, "title": row.Title, "body": canonical.StripManagedBody(string(row.Body)).Body, "state": row.State, "state_reason": row.StateReason, "locked": row.Locked, "labels": labels, "assignees": splitCSV(row.AssigneeLogins), "milestone": milestone})
		setAGSProvenance(&record, row.Author.Login, row.CreatedAt, p.endpoint, "issues/"+strconv.Itoa(row.Number))
		return record, row.UpdatedAt.UTC().Format(timeLayout), "native"
	})
}

func (p *Provider) inventoryIssueComments(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.IssueComment
	if err := p.service.DBForCtx(ctx).Preload("Author").Where("repository_id = ?", p.repository.ID).Find(&rows).Error; err != nil {
		return nil, err
	}
	issueIDs := make(map[int]string)
	pullIDs := make(map[int]string)
	var issues []db.Issue
	if err := p.service.DBForCtx(ctx).Select("id", "number").Where("repository_id = ?", p.repository.ID).Find(&issues).Error; err != nil {
		return nil, err
	}
	for _, issue := range issues {
		issueIDs[issue.Number] = uintString(issue.ID)
	}
	var pulls []db.PullRequest
	if err := p.service.DBForCtx(ctx).Select("id", "number").Where("repository_id = ?", p.repository.ID).Find(&pulls).Error; err != nil {
		return nil, err
	}
	for _, pull := range pulls {
		pullIDs[pull.Number] = uintString(pull.ID)
	}
	return records(rows, func(row db.IssueComment) (canonical.Record, string, string) {
		id := uintString(row.ID)
		fields := map[string]any{"_database_id": row.ID, "_issue_number": row.IssueNumber, "body": canonical.StripManagedBody(string(row.Body)).Body}
		record := canonical.NewRecord(canonical.TypeIssueComment, id, "issue-comment:"+id, fields)
		record.ParentKey = "issue-number:" + strconv.Itoa(row.IssueNumber)
		if pullIdentity := pullIDs[row.IssueNumber]; pullIdentity != "" {
			record.ParentType, record.ParentIdentity = canonical.TypePullRequest, pullIdentity
			record.Fields["_pull_number"] = row.IssueNumber
		} else {
			record.ParentType, record.ParentIdentity = canonical.TypeIssue, issueIDs[row.IssueNumber]
		}
		setAGSProvenance(&record, row.Author.Login, row.CreatedAt, p.endpoint, fmt.Sprintf("issues/%d#issuecomment-%d", row.IssueNumber, row.ID))
		return record, row.UpdatedAt.UTC().Format(timeLayout), "attributed"
	})
}

func (p *Provider) inventoryPullRequests(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.PullRequest
	if err := p.service.DBForCtx(ctx).Preload("Author").Preload("Labels").Preload("Milestone").Preload("HeadRepository").Where("repository_id = ?", p.repository.ID).Find(&rows).Error; err != nil {
		return nil, err
	}
	return records(rows, func(row db.PullRequest) (canonical.Record, string, string) {
		id := uintString(row.ID)
		labels := make([]string, 0, len(row.Labels))
		for _, label := range row.Labels {
			labels = append(labels, label.Name)
		}
		sort.Strings(labels)
		milestone := ""
		if row.Milestone != nil {
			milestone = row.Milestone.Title
		}
		record := canonical.NewRecord(canonical.TypePullRequest, id, "pull-request:"+id, map[string]any{"_database_id": row.ID, "_number": row.Number, "title": row.Title, "body": canonical.StripManagedBody(string(row.Body)).Body, "state": row.State, "draft": row.Draft, "head_ref": row.HeadRef, "head_sha": row.HeadSHA, "_head_repo": row.HeadRepository.FullName, "base_ref": row.BaseRef, "base_sha": row.BaseSHA, "merged": row.Merged, "merge_commit_sha": row.MergeCommitSHA, "labels": labels, "assignees": splitCSV(row.AssigneeLogins), "milestone": milestone})
		p.mu.RLock()
		metadata := append(json.RawMessage(nil), p.linkMetadataByIdentity[identityKey(canonical.TypePullRequest, id)]...)
		p.mu.RUnlock()
		if linked, err := canonical.Decode(metadata); err == nil {
			for _, key := range []string{"_source_head_ref", "_source_head_repo", "_base_repo"} {
				if value := canonical.StringField(linked.Fields, key); value != "" {
					record.Fields[key] = value
				}
			}
		}
		setAGSProvenance(&record, row.Author.Login, row.CreatedAt, p.endpoint, "pull/"+strconv.Itoa(row.Number))
		return record, row.UpdatedAt.UTC().Format(timeLayout), "native"
	})
}

func (p *Provider) inventoryPullRequestReviews(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.PullRequestReview
	if err := p.service.DBForCtx(ctx).Joins("PullRequest").Where("PullRequest.repository_id = ?", p.repository.ID).Find(&rows).Error; err != nil {
		return nil, err
	}
	return records(rows, func(row db.PullRequestReview) (canonical.Record, string, string) {
		id := uintString(row.ID)
		record := canonical.NewRecord(canonical.TypePullRequestReview, id, "pull-review:"+id, map[string]any{"_database_id": row.ID, "_pull_number": row.PullRequest.Number, "_original_state": row.State, "body": canonical.StripManagedBody(string(row.Body)).Body, "commit_id": row.CommitSHA})
		record.ParentType, record.ParentIdentity = canonical.TypePullRequest, uintString(row.PullRequestID)
		record.ParentKey = "pull-number:" + strconv.Itoa(row.PullRequest.Number)
		when := row.CreatedAt
		if row.SubmittedAt != nil {
			when = *row.SubmittedAt
		}
		setAGSProvenance(&record, row.AuthorLogin, when, p.endpoint, fmt.Sprintf("pull/%d#pullrequestreview-%d", row.PullRequest.Number, row.ID))
		return record, row.UpdatedAt.UTC().Format(timeLayout), "attributed"
	})
}

func (p *Provider) inventoryReviewRequests(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.ReviewRequest
	if err := p.service.DBForCtx(ctx).Joins("PullRequest").Where("PullRequest.repository_id = ?", p.repository.ID).Find(&rows).Error; err != nil {
		return nil, err
	}
	type group struct {
		prID         uint
		number       int
		users, teams []string
	}
	groups := map[uint]*group{}
	for _, row := range rows {
		g := groups[row.PullRequestID]
		if g == nil {
			g = &group{prID: row.PullRequestID, number: row.PullRequest.Number}
			groups[row.PullRequestID] = g
		}
		if row.TeamSlug != "" {
			g.teams = append(g.teams, row.TeamSlug)
		} else {
			g.users = append(g.users, row.Login)
		}
	}
	objects := make([]reposync.InventoryObject, 0, len(groups))
	for _, g := range groups {
		sort.Strings(g.users)
		sort.Strings(g.teams)
		id := "pull:" + uintString(g.prID)
		record := canonical.NewRecord(canonical.TypeReviewRequest, id, "review-request:"+id, map[string]any{"_pull_number": g.number, "users": g.users, "teams": g.teams})
		record.ParentType, record.ParentIdentity = canonical.TypePullRequest, uintString(g.prID)
		record.ParentKey = "pull-number:" + strconv.Itoa(g.number)
		object, err := record.Inventory("", "projected")
		if err != nil {
			return nil, err
		}
		objects = append(objects, object)
	}
	return objects, nil
}

func (p *Provider) inventoryPullRequestReviewComments(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.PRReviewComment
	if err := p.service.DBForCtx(ctx).Preload("PullRequest").Where("pull_request_id IN (?)", p.service.DBForCtx(ctx).Model(&db.PullRequest{}).Select("id").Where("repository_id = ?", p.repository.ID)).Find(&rows).Error; err != nil {
		return nil, err
	}
	return records(rows, func(row db.PRReviewComment) (canonical.Record, string, string) {
		id := uintString(row.ID)
		startSide := ""
		if row.StartLine != nil {
			startSide = row.Side
		}
		record := canonical.NewRecord(canonical.TypePullRequestReviewComment, id, "pull-review-comment:"+id, map[string]any{"_database_id": row.ID, "_pull_number": row.PullRequest.Number, "body": canonical.StripManagedBody(string(row.Body)).Body, "commit_id": row.CommitID, "path": row.Path, "line": row.Line, "side": row.Side, "start_line": intPointer(row.StartLine), "start_side": startSide, "_in_reply_to_id": uintPointer(row.InReplyToID)})
		record.ParentType, record.ParentIdentity = canonical.TypePullRequest, uintString(row.PullRequestID)
		record.ParentKey = "pull-number:" + strconv.Itoa(row.PullRequest.Number)
		setAGSProvenance(&record, row.AuthorLogin, row.CreatedAt, p.endpoint, fmt.Sprintf("pull/%d#discussion_r%d", row.PullRequest.Number, row.ID))
		return record, row.UpdatedAt.UTC().Format(timeLayout), "attributed"
	})
}

func (p *Provider) inventoryReleases(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.Release
	if err := p.service.DBForCtx(ctx).Preload("Author").Preload("Assets").Where("repository_id = ?", p.repository.ID).Find(&rows).Error; err != nil {
		return nil, err
	}
	objects := make([]reposync.InventoryObject, 0)
	for _, row := range rows {
		id := uintString(row.ID)
		record := canonical.NewRecord(canonical.TypeRelease, id, "release:"+id, map[string]any{"_database_id": row.ID, "tag_name": row.TagName, "_target_commitish": "", "name": row.Name, "body": canonical.StripManagedBody(string(row.Body)).Body, "draft": row.Draft, "prerelease": row.PreRelease})
		record.UniqueKey = row.TagName
		setAGSProvenance(&record, row.Author.Login, row.CreatedAt, p.endpoint, "releases/"+id)
		object, err := record.Inventory(timeString(row.PublishedAt), "native")
		if err != nil {
			return nil, err
		}
		objects = append(objects, object)
		for _, asset := range row.Assets {
			assetID := uintString(asset.ID)
			assetRecord := canonical.NewRecord(canonical.TypeReleaseAsset, assetID, "release-asset:"+assetID, map[string]any{"_database_id": asset.ID, "_release_id": row.ID, "release_tag": row.TagName, "name": asset.Name, "label": asset.Label, "content_type": asset.ContentType, "size": asset.Size, "sha256": sha256Hex(asset.Content)})
			assetRecord.ParentType, assetRecord.ParentIdentity = canonical.TypeRelease, id
			assetRecord.ParentKey = "release-tag:" + row.TagName
			assetRecord.UniqueKey = row.TagName + "\x00" + asset.Name
			assetObject, err := assetRecord.Inventory(asset.UpdatedAt.UTC().Format(timeLayout), "native")
			if err != nil {
				return nil, err
			}
			objects = append(objects, assetObject)
		}
	}
	return objects, nil
}

func (p *Provider) inventoryReactions(ctx context.Context) ([]reposync.InventoryObject, error) {
	var rows []db.Reaction
	database := p.service.DBForCtx(ctx)
	if err := database.Preload("User").Where("issue_id IN (?) OR issue_id IN (?) OR comment_id IN (?)",
		database.Model(&db.Issue{}).Select("id").Where("repository_id = ?", p.repository.ID),
		database.Model(&db.PullRequest{}).Select("id").Where("repository_id = ?", p.repository.ID),
		database.Model(&db.IssueComment{}).Select("id").Where("repository_id = ?", p.repository.ID)).Find(&rows).Error; err != nil {
		return nil, err
	}
	issueIDs, pullIDs, commentIDs := map[uint]bool{}, map[uint]bool{}, map[uint]bool{}
	var issues []db.Issue
	var pulls []db.PullRequest
	var comments []db.IssueComment
	if err := database.Select("id").Where("repository_id = ?", p.repository.ID).Find(&issues).Error; err != nil {
		return nil, err
	}
	if err := database.Select("id").Where("repository_id = ?", p.repository.ID).Find(&pulls).Error; err != nil {
		return nil, err
	}
	if err := database.Select("id").Where("repository_id = ?", p.repository.ID).Find(&comments).Error; err != nil {
		return nil, err
	}
	for _, row := range issues {
		issueIDs[row.ID] = true
	}
	for _, row := range pulls {
		pullIDs[row.ID] = true
	}
	for _, row := range comments {
		commentIDs[row.ID] = true
	}
	return records(rows, func(row db.Reaction) (canonical.Record, string, string) {
		id := uintString(row.ID)
		record := canonical.NewRecord(canonical.TypeReaction, id, "reaction:"+id, map[string]any{"content": row.Content, "_issue_id": uintPointer(row.IssueID), "_comment_id": uintPointer(row.CommentID)})
		if row.CommentID != nil && commentIDs[*row.CommentID] {
			record.ParentType, record.ParentIdentity = canonical.TypeIssueComment, uintString(*row.CommentID)
		} else if row.IssueID != nil && issueIDs[*row.IssueID] {
			record.ParentType, record.ParentIdentity = canonical.TypeIssue, uintString(*row.IssueID)
		} else if row.IssueID != nil && pullIDs[*row.IssueID] {
			record.ParentType, record.ParentIdentity = canonical.TypePullRequest, uintString(*row.IssueID)
		}
		setAGSProvenance(&record, row.User.Login, row.CreatedAt, p.endpoint, "")
		return record, row.CreatedAt.UTC().Format(timeLayout), "archive_only"
	})
}

func records[T any](rows []T, mapper func(T) (canonical.Record, string, string)) ([]reposync.InventoryObject, error) {
	objects := make([]reposync.InventoryObject, 0, len(rows))
	for _, row := range rows {
		record, revision, representation := mapper(row)
		object, err := record.Inventory(revision, representation)
		if err != nil {
			return nil, err
		}
		objects = append(objects, object)
	}
	return objects, nil
}
func setAGSProvenance(record *canonical.Record, actor string, created time.Time, endpoint reposync.Endpoint, suffix string) {
	record.OriginalActor = actor
	record.OriginalTime = &created
	record.SourceProvider = "ags"
	record.SourceURL = "/" + strings.Trim(endpoint.RepositoryFullName, "/")
	if suffix != "" {
		record.SourceURL += "/" + suffix
	}
}
func uintString(value uint) string { return strconv.FormatUint(uint64(value), 10) }
func timeString(value *time.Time) string {
	if value == nil {
		return ""
	}
	return value.UTC().Format(timeLayout)
}
func splitCSV(value string) []string {
	if strings.TrimSpace(value) == "" {
		return []string{}
	}
	items := strings.Split(value, ",")
	for i := range items {
		items[i] = strings.TrimSpace(items[i])
	}
	sort.Strings(items)
	return items
}
func intPointer(value *int) int {
	if value == nil {
		return 0
	}
	return *value
}
func uintPointer(value *uint) uint {
	if value == nil {
		return 0
	}
	return *value
}
func sha256Hex(value []byte) string                  { sum := sha256.Sum256(value); return hex.EncodeToString(sum[:]) }
func identityKey(objectType, identity string) string { return objectType + "\x00" + identity }

const timeLayout = "2006-01-02T15:04:05Z07:00"
