package ags

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/pingcap/agent-git-service/internal/db"
	"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/gitmirror"
	"github.com/pingcap/agent-git-service/internal/sync/gittransport"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

func (p *Provider) ApplyBatch(ctx context.Context, batch reposync.PhysicalBatch, source reposync.Provider) ([]reposync.ApplyResult, error) {
	results := make([]reposync.ApplyResult, len(batch.Items))
	if batch.ObjectType == canonical.TypeLFSObject {
		sourceRemote, ok := source.(reposync.GitRemoteSource)
		if !ok {
			return nil, fmt.Errorf("source provider does not expose Git transport")
		}
		from, err := sourceRemote.GitRemote(ctx, false)
		if err != nil {
			return nil, err
		}
		to, err := p.GitRemote(ctx, false)
		if err != nil {
			return nil, err
		}
		outcomes, applyErr := gittransport.ApplyLFSBatch(ctx, from, to, batch.Items)
		for index, item := range batch.Items {
			record, _ := canonical.Decode(item.Payload)
			oid := canonical.StringField(record.Fields, "oid")
			result := reposync.ApplyResult{ObjectType: item.ObjectType, ObjectKey: item.ObjectKey, TargetIdentity: oid}
			if item.Action != reposync.ActionDelete && outcomes[item.ObjectKey] == nil && applyErr == nil {
				result.TargetRevision, result.TargetHash = oid+":"+strconv.Itoa(canonical.IntField(record.Fields, "size")), item.DesiredHash
			}
			if outcome := outcomes[item.ObjectKey]; outcome != nil {
				result.ErrorCode, result.ErrorMessage = "lfs_transfer_failed", outcome.Error()
			} else if applyErr != nil {
				result.ErrorCode, result.ErrorMessage = "lfs_transfer_failed", applyErr.Error()
			}
			results[index] = result
		}
		if len(batch.Items) > 0 && batch.Items[0].Action == reposync.ActionDelete {
			if err := p.refreshType(ctx, canonical.TypeLFSObject); err != nil {
				return results, err
			}
		}
		return results, applyErr
	}
	if batch.ObjectType == canonical.TypeGitRef || batch.ObjectType == canonical.TypeWikiRef {
		sourceRemote, ok := source.(reposync.GitRemoteSource)
		if !ok {
			return nil, fmt.Errorf("source provider does not expose Git transport")
		}
		wiki := batch.ObjectType == canonical.TypeWikiRef
		from, err := sourceRemote.GitRemote(ctx, wiki)
		if err != nil {
			return nil, err
		}
		to, err := p.GitRemote(ctx, wiki)
		if err != nil {
			return nil, err
		}
		if wiki && !p.git.Exists(ctx, p.repository.FullName+".wiki") {
			if err := p.git.Init(ctx, p.repository.FullName+".wiki", "main", false); err != nil {
				return nil, err
			}
		}
		var applyErr error
		if batch.Bootstrap && batch.Action == reposync.ActionCreate {
			applyErr = p.bootstrapRefs(ctx, batch.ObjectType, from, to)
			if shadowItems := pullHeadShadowItems(batch.Items); applyErr == nil && len(shadowItems) != 0 {
				applyErr = gittransport.ApplyRefBatch(ctx, from, to, shadowItems)
			}
		} else {
			applyErr = gittransport.ApplyRefBatch(ctx, from, to, batch.Items)
		}
		if wiki && applyErr == nil {
			if _, err := p.service.IngestWikiGit(ctx, p.repository.FullName, service.WikiGitIngestOptions{}); err != nil {
				applyErr = fmt.Errorf("ingest synchronized Wiki refs: %w", err)
			}
		}
		for index, item := range batch.Items {
			identity := item.TargetIdentity
			if identity == "" {
				record, _ := canonical.Decode(item.Payload)
				identity = canonical.StringField(record.Fields, "ref")
			}
			results[index] = applyResult(item, identity, applyErr)
		}
	} else {
		err := p.service.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
			bot, err := ensureSyncBotTx(tx)
			if err != nil {
				return err
			}
			for index, item := range batch.Items {
				identity, applyErr := p.applyMetadataTx(ctx, tx, bot, item, source)
				if applyErr != nil {
					return fmt.Errorf("%s %s: %w", item.ObjectType, item.ObjectKey, applyErr)
				}
				results[index] = applyResult(item, identity, nil)
				if item.ObjectLinkID != nil && identity != "" {
					updates := map[string]any{"status": "active"}
					if p.endpoint.Side == "a" {
						updates["side_a_id"], updates["side_a_object_type"] = identity, item.ObjectType
					} else {
						updates["side_b_id"], updates["side_b_object_type"] = identity, item.ObjectType
					}
					if err := tx.Model(&db.SyncObjectLink{}).Where("id = ?", *item.ObjectLinkID).Updates(updates).Error; err != nil {
						return err
					}
				}
			}
			return nil
		})
		if err != nil {
			for index, item := range batch.Items {
				results[index] = applyResult(item, "", err)
			}
		}
	}
	if err := p.refreshType(ctx, batch.ObjectType); err != nil {
		return results, err
	}
	for index, result := range results {
		if result.ErrorCode != "" || batch.Items[index].Action == reposync.ActionDelete {
			continue
		}
		if object, ok := p.cached(batch.ObjectType, result.TargetIdentity); ok {
			results[index].TargetRevision, results[index].TargetHash = object.Revision, object.ContentHash
		} else {
			results[index].Unknown = true
			results[index].ErrorMessage = "AGS object absent from read-back inventory"
		}
	}
	return results, nil
}

func (p *Provider) bootstrapRefs(ctx context.Context, objectType string, from, to reposync.GitRemote) error {
	p.mu.RLock()
	completed := p.bootstrapRefTypes[objectType]
	p.mu.RUnlock()
	if completed {
		return nil
	}
	if err := gitmirror.MirrorWritableRefs(ctx, from.URL, from.Token, to.URL, to.Token, ""); err != nil {
		return err
	}
	p.mu.Lock()
	if p.bootstrapRefTypes == nil {
		p.bootstrapRefTypes = make(map[string]bool)
	}
	p.bootstrapRefTypes[objectType] = true
	p.mu.Unlock()
	return nil
}

func pullHeadShadowItems(items []reposync.PlannedItem) []reposync.PlannedItem {
	result := make([]reposync.PlannedItem, 0)
	for _, item := range items {
		record, err := canonical.Decode(item.Payload)
		if err == nil && canonical.StringField(record.Fields, "_source_ref") != "" {
			result = append(result, item)
		}
	}
	return result
}

func (p *Provider) Verify(_ context.Context, item reposync.PlannedItem, result reposync.ApplyResult) (reposync.TargetState, error) {
	if item.ObjectType == canonical.TypeLFSObject && item.Action != reposync.ActionDelete && result.TargetHash == item.DesiredHash {
		return reposync.TargetState{Exists: true, Identity: result.TargetIdentity, Revision: result.TargetRevision, Hash: result.TargetHash}, nil
	}
	identity := result.TargetIdentity
	if identity == "" {
		identity = item.TargetIdentity
	}
	object, found := p.cached(item.ObjectType, identity)
	if item.Action == reposync.ActionDelete {
		return reposync.TargetState{Exists: found, Identity: identity}, nil
	}
	if !found {
		return reposync.TargetState{}, reposync.ErrObjectNotFound
	}
	return canonical.TargetStateFromInventory(object, true), nil
}

func (p *Provider) OpenBlob(ctx context.Context, item reposync.PlannedItem) (io.ReadCloser, int64, string, error) {
	record, err := canonical.Decode(item.Payload)
	if err != nil {
		return nil, 0, "", err
	}
	if record.Type != canonical.TypeReleaseAsset {
		return nil, 0, "", fmt.Errorf("%s is not a blob-backed object", record.Type)
	}
	id, err := strconv.ParseUint(record.Identity, 10, 64)
	if err != nil {
		return nil, 0, "", err
	}
	var asset db.ReleaseAsset
	if err := p.service.DBForCtx(ctx).First(&asset, "id = ?", uint(id)).Error; err != nil {
		return nil, 0, "", err
	}
	return io.NopCloser(bytes.NewReader(asset.Content)), asset.Size, sha256Hex(asset.Content), nil
}

func (p *Provider) applyMetadataTx(ctx context.Context, tx *gorm.DB, bot db.User, item reposync.PlannedItem, source reposync.Provider) (string, error) {
	record, err := canonical.Decode(item.Payload)
	if err != nil {
		return "", err
	}
	switch record.Type {
	case canonical.TypeLabel:
		return p.applyLabelTx(tx, item, record)
	case canonical.TypeMilestone:
		return p.applyMilestoneTx(tx, bot, item, record)
	case canonical.TypeIssue:
		return p.applyIssueTx(tx, bot, item, record)
	case canonical.TypeIssueComment:
		return p.applyIssueCommentTx(tx, bot, item, record)
	case canonical.TypePullRequest:
		return p.applyPullRequestTx(tx, bot, item, record)
	case canonical.TypePullRequestReview:
		return p.applyReviewTx(tx, bot, item, record)
	case canonical.TypeReviewRequest:
		return p.applyReviewRequestTx(tx, item, record)
	case canonical.TypePullRequestReviewComment:
		return p.applyReviewCommentTx(tx, bot, item, record)
	case canonical.TypeRelease:
		return p.applyReleaseTx(tx, bot, item, record)
	case canonical.TypeReleaseAsset:
		return p.applyReleaseAssetTx(ctx, tx, item, record, source)
	case canonical.TypeReaction:
		return "", fmt.Errorf("reactions are archive_only")
	default:
		return "", fmt.Errorf("unsupported AGS sync object type %q", record.Type)
	}
}

func (p *Provider) applyLabelTx(tx *gorm.DB, item reposync.PlannedItem, record canonical.Record) (string, error) {
	fields := record.Fields
	switch item.Action {
	case reposync.ActionCreate:
		row := db.Label{RepositoryID: p.repository.ID, Name: canonical.StringField(fields, "name"), Color: strings.TrimPrefix(canonical.StringField(fields, "color"), "#"), Description: canonical.StringField(fields, "description")}
		if err := tx.Create(&row).Error; err != nil {
			return "", err
		}
		return uintString(row.ID), nil
	case reposync.ActionUpdate:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Model(&db.Label{}).Where("id = ? AND repository_id = ?", id, p.repository.ID).Updates(map[string]any{"name": canonical.StringField(fields, "name"), "color": strings.TrimPrefix(canonical.StringField(fields, "color"), "#"), "description": canonical.StringField(fields, "description")}).Error
	case reposync.ActionDelete:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		for _, table := range []string{"issue_labels", "pr_labels", "wiki_page_labels"} {
			if err := tx.Exec("DELETE FROM "+table+" WHERE label_id = ?", id).Error; err != nil {
				return "", err
			}
		}
		return uintString(id), tx.Where("id = ? AND repository_id = ?", id, p.repository.ID).Delete(&db.Label{}).Error
	default:
		return "", fmt.Errorf("unsupported label action")
	}
}

func (p *Provider) applyMilestoneTx(tx *gorm.DB, bot db.User, item reposync.PlannedItem, record canonical.Record) (string, error) {
	fields := record.Fields
	switch item.Action {
	case reposync.ActionCreate:
		var maxNumber int
		if err := tx.Model(&db.Milestone{}).Where("repository_id = ?", p.repository.ID).Select("COALESCE(MAX(number), 0)").Scan(&maxNumber).Error; err != nil {
			return "", err
		}
		row := db.Milestone{RepositoryID: p.repository.ID, Number: maxNumber + 1, Title: canonical.StringField(fields, "title"), Description: canonical.StringField(fields, "description"), State: canonical.StringField(fields, "state"), CreatorID: bot.ID, DueOn: parseOptionalTime(canonical.StringField(fields, "due_on")), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
		if err := tx.Create(&row).Error; err != nil {
			return "", err
		}
		return uintString(row.ID), nil
	case reposync.ActionUpdate:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Model(&db.Milestone{}).Where("id = ? AND repository_id = ?", id, p.repository.ID).Updates(map[string]any{"title": canonical.StringField(fields, "title"), "description": canonical.StringField(fields, "description"), "state": canonical.StringField(fields, "state"), "due_on": parseOptionalTime(canonical.StringField(fields, "due_on"))}).Error
	case reposync.ActionDelete:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		if err := tx.Model(&db.Issue{}).Where("milestone_id = ?", id).Update("milestone_id", nil).Error; err != nil {
			return "", err
		}
		if err := tx.Model(&db.PullRequest{}).Where("milestone_id = ?", id).Update("milestone_id", nil).Error; err != nil {
			return "", err
		}
		return uintString(id), tx.Where("id = ? AND repository_id = ?", id, p.repository.ID).Delete(&db.Milestone{}).Error
	default:
		return "", fmt.Errorf("unsupported milestone action")
	}
}

func (p *Provider) applyIssueTx(tx *gorm.DB, bot db.User, item reposync.PlannedItem, record canonical.Record) (string, error) {
	fields := record.Fields
	switch item.Action {
	case reposync.ActionCreate:
		author, err := syncedAuthorTx(tx, bot, record)
		if err != nil {
			return "", err
		}
		number, err := nextIssuePRNumberTx(tx, p.repository.ID)
		if err != nil {
			return "", err
		}
		milestoneID, err := p.milestoneIDByTitleTx(tx, canonical.StringField(fields, "milestone"))
		if err != nil {
			return "", err
		}
		row := db.Issue{Number: number, RepositoryID: p.repository.ID, Title: canonical.StringField(fields, "title"), Body: db.LargeText(canonical.StringField(fields, "body")), State: canonical.StringField(fields, "state"), StateReason: canonical.StringField(fields, "state_reason"), Locked: canonical.BoolField(fields, "locked"), MilestoneID: milestoneID, AuthorID: author.ID, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
		if row.State == "" {
			row.State = "open"
		}
		if err := tx.Create(&row).Error; err != nil {
			return "", err
		}
		if err := p.replaceIssueLabelsTx(tx, &row, stringSlice(fields["labels"])); err != nil {
			return "", err
		}
		return uintString(row.ID), nil
	case reposync.ActionUpdate:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		milestoneID, err := p.milestoneIDByTitleTx(tx, canonical.StringField(fields, "milestone"))
		if err != nil {
			return "", err
		}
		updates := map[string]any{"title": canonical.StringField(fields, "title"), "body": db.LargeText(canonical.StringField(fields, "body")), "state": canonical.StringField(fields, "state"), "state_reason": canonical.StringField(fields, "state_reason"), "locked": canonical.BoolField(fields, "locked"), "milestone_id": milestoneID}
		if err := tx.Model(&db.Issue{}).Where("id = ? AND repository_id = ?", id, p.repository.ID).Updates(updates).Error; err != nil {
			return "", err
		}
		row := db.Issue{ID: id, RepositoryID: p.repository.ID}
		if err := p.replaceIssueLabelsTx(tx, &row, stringSlice(fields["labels"])); err != nil {
			return "", err
		}
		return uintString(id), nil
	case reposync.ActionDelete:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Where("id = ? AND repository_id = ?", id, p.repository.ID).Delete(&db.Issue{}).Error
	default:
		return "", fmt.Errorf("unsupported issue action")
	}
}

func (p *Provider) applyIssueCommentTx(tx *gorm.DB, bot db.User, item reposync.PlannedItem, record canonical.Record) (string, error) {
	switch item.Action {
	case reposync.ActionCreate:
		author, err := syncedAuthorTx(tx, bot, record)
		if err != nil {
			return "", err
		}
		parentType := record.ParentType
		if parentType == "" {
			parentType = canonical.TypeIssue
		}
		number, err := p.parentNumber(tx, record, parentType)
		if err != nil {
			return "", err
		}
		row := db.IssueComment{RepositoryID: p.repository.ID, IssueNumber: number, Body: db.LargeText(canonical.StringField(record.Fields, "body")), AuthorID: author.ID, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
		if err := tx.Create(&row).Error; err != nil {
			return "", err
		}
		return uintString(row.ID), nil
	case reposync.ActionUpdate:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Model(&db.IssueComment{}).Where("id = ? AND repository_id = ?", id, p.repository.ID).Update("body", db.LargeText(canonical.StringField(record.Fields, "body"))).Error
	case reposync.ActionDelete:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Where("id = ? AND repository_id = ?", id, p.repository.ID).Delete(&db.IssueComment{}).Error
	default:
		return "", fmt.Errorf("unsupported issue comment action")
	}
}

func (p *Provider) applyPullRequestTx(tx *gorm.DB, bot db.User, item reposync.PlannedItem, record canonical.Record) (string, error) {
	fields := record.Fields
	switch item.Action {
	case reposync.ActionCreate:
		author, err := syncedAuthorTx(tx, bot, record)
		if err != nil {
			return "", err
		}
		number, err := nextIssuePRNumberTx(tx, p.repository.ID)
		if err != nil {
			return "", err
		}
		milestoneID, err := p.milestoneIDByTitleTx(tx, canonical.StringField(fields, "milestone"))
		if err != nil {
			return "", err
		}
		row := db.PullRequest{Number: number, RepositoryID: p.repository.ID, HeadRepositoryID: p.repository.ID, Title: canonical.StringField(fields, "title"), Body: db.LargeText(canonical.StringField(fields, "body")), State: canonical.StringField(fields, "state"), AuthorID: author.ID, MilestoneID: milestoneID, HeadRef: canonical.StringField(fields, "head_ref"), HeadSHA: canonical.StringField(fields, "head_sha"), BaseRef: canonical.StringField(fields, "base_ref"), BaseSHA: canonical.StringField(fields, "base_sha"), Draft: canonical.BoolField(fields, "draft"), Merged: canonical.BoolField(fields, "merged"), MergeCommitSHA: canonical.StringField(fields, "merge_commit_sha"), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
		if row.State == "" {
			row.State = "open"
		}
		if err := tx.Create(&row).Error; err != nil {
			return "", err
		}
		if err := p.replacePRLabelsTx(tx, &row, stringSlice(fields["labels"])); err != nil {
			return "", err
		}
		return uintString(row.ID), nil
	case reposync.ActionUpdate:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		milestoneID, err := p.milestoneIDByTitleTx(tx, canonical.StringField(fields, "milestone"))
		if err != nil {
			return "", err
		}
		updates := map[string]any{"title": canonical.StringField(fields, "title"), "body": db.LargeText(canonical.StringField(fields, "body")), "state": canonical.StringField(fields, "state"), "head_ref": canonical.StringField(fields, "head_ref"), "head_sha": canonical.StringField(fields, "head_sha"), "base_ref": canonical.StringField(fields, "base_ref"), "base_sha": canonical.StringField(fields, "base_sha"), "draft": canonical.BoolField(fields, "draft"), "merged": canonical.BoolField(fields, "merged"), "merge_commit_sha": canonical.StringField(fields, "merge_commit_sha"), "milestone_id": milestoneID}
		if err := tx.Model(&db.PullRequest{}).Where("id = ? AND repository_id = ?", id, p.repository.ID).Updates(updates).Error; err != nil {
			return "", err
		}
		row := db.PullRequest{ID: id, RepositoryID: p.repository.ID}
		if err := p.replacePRLabelsTx(tx, &row, stringSlice(fields["labels"])); err != nil {
			return "", err
		}
		return uintString(id), nil
	case reposync.ActionDelete:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Where("id = ? AND repository_id = ?", id, p.repository.ID).Delete(&db.PullRequest{}).Error
	default:
		return "", fmt.Errorf("unsupported pull request action")
	}
}

func (p *Provider) applyReviewTx(tx *gorm.DB, bot db.User, item reposync.PlannedItem, record canonical.Record) (string, error) {
	switch item.Action {
	case reposync.ActionCreate:
		author, err := syncedAuthorTx(tx, bot, record)
		if err != nil {
			return "", err
		}
		prID, err := p.parentID(tx, record, canonical.TypePullRequest)
		if err != nil {
			return "", err
		}
		row := db.PullRequestReview{PullRequestID: prID, AuthorLogin: author.Login, State: "COMMENTED", Body: db.LargeText(canonical.StringField(record.Fields, "body")), CommitSHA: canonical.StringField(record.Fields, "commit_id"), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
		if err := tx.Create(&row).Error; err != nil {
			return "", err
		}
		return uintString(row.ID), nil
	case reposync.ActionUpdate:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Model(&db.PullRequestReview{}).Where("id = ?", id).Update("body", db.LargeText(canonical.StringField(record.Fields, "body"))).Error
	case reposync.ActionDelete:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Delete(&db.PullRequestReview{}, id).Error
	default:
		return "", fmt.Errorf("unsupported review action")
	}
}

func (p *Provider) applyReviewRequestTx(tx *gorm.DB, item reposync.PlannedItem, record canonical.Record) (string, error) {
	prID, err := p.parentID(tx, record, canonical.TypePullRequest)
	if err != nil {
		return "", err
	}
	if err := tx.Where("pull_request_id = ?", prID).Delete(&db.ReviewRequest{}).Error; err != nil {
		return "", err
	}
	if item.Action == reposync.ActionDelete {
		return "pull:" + uintString(prID), nil
	}
	for _, login := range stringSlice(record.Fields["users"]) {
		if err := tx.Create(&db.ReviewRequest{PullRequestID: prID, Login: login}).Error; err != nil {
			return "", err
		}
	}
	for _, slug := range stringSlice(record.Fields["teams"]) {
		if err := tx.Create(&db.ReviewRequest{PullRequestID: prID, TeamSlug: slug}).Error; err != nil {
			return "", err
		}
	}
	return "pull:" + uintString(prID), nil
}

func (p *Provider) applyReviewCommentTx(tx *gorm.DB, bot db.User, item reposync.PlannedItem, record canonical.Record) (string, error) {
	switch item.Action {
	case reposync.ActionCreate:
		author, err := syncedAuthorTx(tx, bot, record)
		if err != nil {
			return "", err
		}
		prID, err := p.parentID(tx, record, canonical.TypePullRequest)
		if err != nil {
			return "", err
		}
		row := db.PRReviewComment{PullRequestID: prID, AuthorLogin: author.Login, Body: db.LargeText(canonical.StringField(record.Fields, "body")), CommitID: canonical.StringField(record.Fields, "commit_id"), Path: canonical.StringField(record.Fields, "path"), Line: canonical.IntField(record.Fields, "line"), Side: canonical.StringField(record.Fields, "side"), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
		if start := canonical.IntField(record.Fields, "start_line"); start > 0 {
			row.StartLine = &start
		}
		if err := tx.Create(&row).Error; err != nil {
			return "", err
		}
		return uintString(row.ID), nil
	case reposync.ActionUpdate:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Model(&db.PRReviewComment{}).Where("id = ?", id).Update("body", db.LargeText(canonical.StringField(record.Fields, "body"))).Error
	case reposync.ActionDelete:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Delete(&db.PRReviewComment{}, id).Error
	default:
		return "", fmt.Errorf("unsupported review comment action")
	}
}

func (p *Provider) applyReleaseTx(tx *gorm.DB, bot db.User, item reposync.PlannedItem, record canonical.Record) (string, error) {
	fields := record.Fields
	switch item.Action {
	case reposync.ActionCreate:
		author, err := syncedAuthorTx(tx, bot, record)
		if err != nil {
			return "", err
		}
		row := db.Release{RepositoryID: p.repository.ID, TagName: canonical.StringField(fields, "tag_name"), Name: canonical.StringField(fields, "name"), Body: db.LargeText(canonical.StringField(fields, "body")), Draft: canonical.BoolField(fields, "draft"), PreRelease: canonical.BoolField(fields, "prerelease"), AuthorID: author.ID, CreatedAt: time.Now().UTC()}
		if err := tx.Create(&row).Error; err != nil {
			return "", err
		}
		return uintString(row.ID), nil
	case reposync.ActionUpdate:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Model(&db.Release{}).Where("id = ? AND repository_id = ?", id, p.repository.ID).Updates(map[string]any{"tag_name": canonical.StringField(fields, "tag_name"), "name": canonical.StringField(fields, "name"), "body": db.LargeText(canonical.StringField(fields, "body")), "draft": canonical.BoolField(fields, "draft"), "pre_release": canonical.BoolField(fields, "prerelease")}).Error
	case reposync.ActionDelete:
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Where("id = ? AND repository_id = ?", id, p.repository.ID).Delete(&db.Release{}).Error
	default:
		return "", fmt.Errorf("unsupported release action")
	}
}

func (p *Provider) applyReleaseAssetTx(ctx context.Context, tx *gorm.DB, item reposync.PlannedItem, record canonical.Record, source reposync.Provider) (string, error) {
	if item.Action == reposync.ActionDelete {
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Delete(&db.ReleaseAsset{}, id).Error
	}
	blobSource, ok := source.(reposync.BlobSource)
	if !ok {
		return "", fmt.Errorf("source provider cannot stream release assets")
	}
	reader, size, checksum, err := blobSource.OpenBlob(ctx, item)
	if err != nil {
		return "", err
	}
	defer reader.Close()
	content, err := io.ReadAll(io.LimitReader(reader, size+1))
	if err != nil {
		return "", err
	}
	if int64(len(content)) != size || sha256Hex(content) != checksum {
		return "", fmt.Errorf("release asset checksum mismatch")
	}
	releaseID, err := p.parentID(tx, record, canonical.TypeRelease)
	if err != nil {
		return "", err
	}
	if item.Action == reposync.ActionUpdate {
		id, err := targetUint(item)
		if err != nil {
			return "", err
		}
		return uintString(id), tx.Model(&db.ReleaseAsset{}).Where("id = ?", id).Updates(map[string]any{"release_id": releaseID, "name": canonical.StringField(record.Fields, "name"), "label": canonical.StringField(record.Fields, "label"), "content_type": canonical.StringField(record.Fields, "content_type"), "size": size, "content": content}).Error
	}
	row := db.ReleaseAsset{ReleaseID: releaseID, Name: canonical.StringField(record.Fields, "name"), Label: canonical.StringField(record.Fields, "label"), ContentType: canonical.StringField(record.Fields, "content_type"), Size: size, Content: content}
	if err := tx.Create(&row).Error; err != nil {
		return "", err
	}
	return uintString(row.ID), nil
}

func (p *Provider) refreshType(ctx context.Context, objectType string) error {
	var objects []reposync.InventoryObject
	var err error
	switch objectType {
	case canonical.TypeGitRef:
		objects, err = p.inventoryGitRefs(ctx)
	case canonical.TypeWikiRef:
		objects, err = p.inventoryWikiRefs(ctx)
	case canonical.TypeLFSObject:
		objects, err = p.inventoryLFSObjects(ctx)
	case canonical.TypeLabel:
		objects, err = p.inventoryLabels(ctx)
	case canonical.TypeMilestone:
		objects, err = p.inventoryMilestones(ctx)
	case canonical.TypeIssue:
		objects, err = p.inventoryIssues(ctx)
	case canonical.TypeIssueComment:
		objects, err = p.inventoryIssueComments(ctx)
	case canonical.TypePullRequest:
		objects, err = p.inventoryPullRequests(ctx)
	case canonical.TypePullRequestReview:
		objects, err = p.inventoryPullRequestReviews(ctx)
	case canonical.TypeReviewRequest:
		objects, err = p.inventoryReviewRequests(ctx)
	case canonical.TypePullRequestReviewComment:
		objects, err = p.inventoryPullRequestReviewComments(ctx)
	case canonical.TypeRelease, canonical.TypeReleaseAsset:
		objects, err = p.inventoryReleases(ctx)
	default:
		return fmt.Errorf("unsupported AGS inventory type %q", objectType)
	}
	if err != nil {
		return err
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	for key, object := range p.inventoryByIdentity {
		if object.ObjectType == objectType || (objectType == canonical.TypeRelease && object.ObjectType == canonical.TypeReleaseAsset) || (objectType == canonical.TypeReleaseAsset && object.ObjectType == canonical.TypeRelease) {
			delete(p.inventoryByIdentity, key)
		}
	}
	for _, object := range objects {
		p.inventoryByIdentity[identityKey(object.ObjectType, object.Identity)] = object
	}
	return nil
}
func (p *Provider) cached(objectType, identity string) (reposync.InventoryObject, bool) {
	p.mu.RLock()
	defer p.mu.RUnlock()
	object, ok := p.inventoryByIdentity[identityKey(objectType, identity)]
	return object, ok
}
func (p *Provider) parentID(tx *gorm.DB, record canonical.Record, objectType string) (uint, error) {
	if record.ParentIdentity != "" {
		id, err := strconv.ParseUint(record.ParentIdentity, 10, 64)
		if err == nil {
			return uint(id), nil
		}
	}
	numberKey := "_issue_number"
	model := any(&db.Issue{})
	if objectType == canonical.TypePullRequest {
		numberKey = "_pull_number"
		model = &db.PullRequest{}
	} else if objectType == canonical.TypeRelease {
		tag := canonical.StringField(record.Fields, "release_tag")
		var row db.Release
		if err := tx.Where("repository_id = ? AND tag_name = ?", p.repository.ID, tag).First(&row).Error; err != nil {
			return 0, err
		}
		return row.ID, nil
	}
	number := canonical.IntField(record.Fields, numberKey)
	var id uint
	if err := tx.Model(model).Select("id").Where("repository_id = ? AND number = ?", p.repository.ID, number).Scan(&id).Error; err != nil {
		return 0, err
	}
	if id == 0 {
		return 0, reposync.ErrObjectNotFound
	}
	return id, nil
}
func (p *Provider) parentNumber(tx *gorm.DB, record canonical.Record, objectType string) (int, error) {
	id, err := p.parentID(tx, record, objectType)
	if err != nil {
		return 0, err
	}
	var number int
	model := any(&db.Issue{})
	if objectType == canonical.TypePullRequest {
		model = &db.PullRequest{}
	}
	if err := tx.Model(model).Select("number").Where("id = ?", id).Scan(&number).Error; err != nil {
		return 0, err
	}
	return number, nil
}
func (p *Provider) replaceIssueLabelsTx(tx *gorm.DB, row *db.Issue, names []string) error {
	labels, err := p.labelsByNameTx(tx, names)
	if err != nil {
		return err
	}
	return tx.Model(row).Association("Labels").Replace(labels)
}
func (p *Provider) replacePRLabelsTx(tx *gorm.DB, row *db.PullRequest, names []string) error {
	labels, err := p.labelsByNameTx(tx, names)
	if err != nil {
		return err
	}
	return tx.Model(row).Association("Labels").Replace(labels)
}
func (p *Provider) labelsByNameTx(tx *gorm.DB, names []string) ([]db.Label, error) {
	if len(names) == 0 {
		return nil, nil
	}
	var labels []db.Label
	err := tx.Where("repository_id = ? AND name IN ?", p.repository.ID, names).Find(&labels).Error
	return labels, err
}
func (p *Provider) milestoneIDByTitleTx(tx *gorm.DB, title string) (*uint, error) {
	if strings.TrimSpace(title) == "" {
		return nil, nil
	}
	var row db.Milestone
	if err := tx.Where("repository_id = ? AND LOWER(title) = LOWER(?)", p.repository.ID, title).Take(&row).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			return nil, fmt.Errorf("target milestone %q is not resolved", title)
		}
		return nil, err
	}
	return &row.ID, nil
}
func parseOptionalTime(value string) *time.Time {
	if strings.TrimSpace(value) == "" {
		return nil
	}
	parsed, err := time.Parse(time.RFC3339, value)
	if err != nil {
		return nil
	}
	return &parsed
}
func ensureSyncBotTx(tx *gorm.DB) (db.User, error) {
	row := db.User{Login: "ags-sync[bot]", Name: "AGS Repository Sync", Type: db.TypeUser, UserKind: db.UserKindAgent, Status: db.UserStatusActive}
	if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "login"}}, DoNothing: true}).Create(&row).Error; err != nil {
		return db.User{}, err
	}
	if err := tx.Where("login = ?", row.Login).First(&row).Error; err != nil {
		return db.User{}, err
	}
	return row, nil
}

// syncedAuthorTx preserves the source author as display metadata without
// granting that account any AGS credentials. If the person later has a real
// AGS account with the same login, the existing identity is reused.
func syncedAuthorTx(tx *gorm.DB, bot db.User, record canonical.Record) (db.User, error) {
	login := strings.TrimSpace(record.OriginalActor)
	if login == "" || login == bot.Login {
		return bot, nil
	}
	row := db.User{Login: login, Name: login, Type: db.TypeUser, UserKind: db.UserKindHuman, Status: db.UserStatusActive}
	if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "login"}}, DoNothing: true}).Create(&row).Error; err != nil {
		return db.User{}, err
	}
	if err := tx.Where("login = ?", login).First(&row).Error; err != nil {
		return db.User{}, err
	}
	return row, nil
}
func nextIssuePRNumberTx(tx *gorm.DB, repoID uint) (int, error) {
	if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&db.Repository{}, "id = ?", repoID).Error; err != nil {
		return 0, err
	}
	var issueMax, prMax int
	if err := tx.Model(&db.Issue{}).Where("repository_id = ?", repoID).Select("COALESCE(MAX(number),0)").Scan(&issueMax).Error; err != nil {
		return 0, err
	}
	if err := tx.Model(&db.PullRequest{}).Where("repository_id = ?", repoID).Select("COALESCE(MAX(number),0)").Scan(&prMax).Error; err != nil {
		return 0, err
	}
	if prMax > issueMax {
		issueMax = prMax
	}
	return issueMax + 1, nil
}
func targetUint(item reposync.PlannedItem) (uint, error) {
	value, err := strconv.ParseUint(item.TargetIdentity, 10, 64)
	if err != nil || value == 0 {
		return 0, fmt.Errorf("invalid AGS target identity %q", item.TargetIdentity)
	}
	return uint(value), nil
}
func applyResult(item reposync.PlannedItem, identity string, err error) reposync.ApplyResult {
	result := reposync.ApplyResult{ObjectType: item.ObjectType, ObjectKey: item.ObjectKey, TargetIdentity: identity}
	if err != nil {
		result.ErrorCode = "ags_apply_failed"
		result.ErrorMessage = err.Error()
	}
	return result
}
func stringSlice(value any) []string {
	if typed, ok := value.([]string); ok {
		return append([]string(nil), typed...)
	}
	items, _ := value.([]any)
	result := make([]string, 0, len(items))
	for _, item := range items {
		if text, ok := item.(string); ok {
			result = append(result, text)
		}
	}
	sort.Strings(result)
	return result
}

var _ reposync.BlobSource = (*Provider)(nil)
