package github

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"sync"

	reposync "github.com/pingcap/agent-git-service/internal/sync"
	"github.com/pingcap/agent-git-service/internal/sync/bulk"
	"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"
)

func (p *Provider) ApplyBatch(ctx context.Context, batch reposync.PhysicalBatch, source reposync.Provider) ([]reposync.ApplyResult, error) {
	p.trustBatchObjectLinks(batch.Items)
	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 itemAction(batch.Items) == 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
		}
		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)
		}
		for index, item := range batch.Items {
			identity := item.TargetIdentity
			if identity == "" {
				record, _ := canonical.Decode(item.Payload)
				identity = canonical.StringField(record.Fields, "ref")
			}
			results[index] = reposync.ApplyResult{ObjectType: item.ObjectType, ObjectKey: item.ObjectKey, TargetIdentity: identity}
			if applyErr != nil {
				results[index].ErrorCode, results[index].ErrorMessage = "git_push_failed", applyErr.Error()
			}
		}
		if err := p.refreshType(ctx, batch.ObjectType); err != nil {
			return results, err
		}
		return results, nil
	}
	_ = bulk.ForEach(ctx, len(batch.Items), p.maxConcurrency, func(groupCtx context.Context, index int) error {
		item := batch.Items[index]
		result := reposync.ApplyResult{ObjectType: item.ObjectType, ObjectKey: item.ObjectKey}
		identity, err := p.applyOne(groupCtx, item, source)
		if err != nil {
			result.ErrorCode, result.ErrorMessage = classifyApplyError(err), err.Error()
			if errors.Is(err, reposync.ErrResultUnknown) {
				result.Unknown = true
			}
		} else {
			result.TargetIdentity = identity
		}
		results[index] = result
		return nil
	})

	if err := p.refreshType(ctx, batch.ObjectType); err != nil {
		return results, fmt.Errorf("refresh GitHub %s after batch: %w", batch.ObjectType, err)
	}
	for index := range results {
		if results[index].ErrorCode != "" || results[index].Unknown {
			continue
		}
		item := batch.Items[index]
		if item.Action == reposync.ActionDelete {
			continue
		}
		object, ok := p.cached(item.ObjectType, results[index].TargetIdentity)
		if !ok {
			results[index].Unknown = true
			results[index].ErrorMessage = "object absent from read-back inventory"
			continue
		}
		results[index].TargetRevision, results[index].TargetHash = object.Revision, object.ContentHash
	}
	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 {
		if found && (item.ObjectType == canonical.TypeIssue || item.ObjectType == canonical.TypePullRequest) {
			record, err := canonical.Decode(object.Payload)
			if err != nil {
				return reposync.TargetState{}, err
			}
			// GitHub has no delete API for Issues/PRs. Closing is the
			// documented projected deletion and therefore satisfies this
			// logical RunItem while the closed carrier remains on GitHub.
			if canonical.StringField(record.Fields, "state") == "closed" {
				return reposync.TargetState{Exists: false, Identity: identity, Revision: object.Revision, Hash: object.ContentHash}, nil
			}
		}
		return reposync.TargetState{Exists: found, Identity: identity}, nil
	}
	if !found {
		return reposync.TargetState{}, reposync.ErrObjectNotFound
	}
	return canonical.TargetStateFromInventory(object, true), nil
}

func (p *Provider) applyOne(ctx context.Context, item reposync.PlannedItem, source reposync.Provider) (string, error) {
	record, err := canonical.Decode(item.Payload)
	if err != nil {
		return "", err
	}
	switch record.Type {
	case canonical.TypeGitRef, canonical.TypeWikiRef:
		return "", fmt.Errorf("Git refs must execute through the batch transport")
	case canonical.TypeLabel:
		return p.applyLabel(ctx, item, record)
	case canonical.TypeMilestone:
		return p.applyMilestone(ctx, item, record)
	case canonical.TypeIssue:
		return p.applyIssue(ctx, item, record)
	case canonical.TypeIssueComment:
		return p.applyIssueComment(ctx, item, record)
	case canonical.TypePullRequest:
		return p.applyPullRequest(ctx, item, record)
	case canonical.TypePullRequestReview:
		return p.applyPullRequestReview(ctx, item, record)
	case canonical.TypeReviewRequest:
		return p.applyReviewRequest(ctx, item, record)
	case canonical.TypePullRequestReviewComment:
		return p.applyPullRequestReviewComment(ctx, item, record)
	case canonical.TypeRelease:
		return p.applyRelease(ctx, item, record)
	case canonical.TypeReleaseAsset:
		return p.applyReleaseAsset(ctx, item, record, source)
	case canonical.TypeReaction:
		return "", fmt.Errorf("reactions are archive_only and cannot be applied as another actor")
	default:
		return "", fmt.Errorf("unsupported GitHub sync object type %q", record.Type)
	}
}

func (p *Provider) applyLabel(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	body := map[string]any{"name": canonical.StringField(record.Fields, "name"), "color": canonical.StringField(record.Fields, "color"), "description": canonical.StringField(record.Fields, "description")}
	var response map[string]any
	switch item.Action {
	case reposync.ActionCreate:
		if err := p.client.doJSON(ctx, http.MethodPost, p.repoPath()+"/labels", nil, body, &response); err != nil {
			return "", err
		}
		return idString(response), nil
	case reposync.ActionUpdate:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		name := canonical.StringField(target.Fields, "name")
		if err := p.client.doJSON(ctx, http.MethodPatch, p.repoPath()+"/labels/"+url.PathEscape(name), nil, body, &response); err != nil {
			return "", err
		}
		return idString(response), nil
	case reposync.ActionDelete:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		if err := p.client.doJSON(ctx, http.MethodDelete, p.repoPath()+"/labels/"+url.PathEscape(canonical.StringField(target.Fields, "name")), nil, nil, nil); err != nil && !isNotFound(err) {
			return "", err
		}
		return item.TargetIdentity, nil
	default:
		return "", fmt.Errorf("unsupported label action %q", item.Action)
	}
}

func (p *Provider) applyMilestone(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	body := map[string]any{"title": canonical.StringField(record.Fields, "title"), "description": canonical.StringField(record.Fields, "description"), "state": canonical.StringField(record.Fields, "state")}
	if due := canonical.StringField(record.Fields, "due_on"); due != "" {
		body["due_on"] = due
	}
	var response map[string]any
	switch item.Action {
	case reposync.ActionCreate:
		if err := p.client.doJSON(ctx, http.MethodPost, p.repoPath()+"/milestones", nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionUpdate:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/milestones/%d", p.repoPath(), canonical.IntField(target.Fields, "_number"))
		if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionDelete:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/milestones/%d", p.repoPath(), canonical.IntField(target.Fields, "_number"))
		if err := p.client.doJSON(ctx, http.MethodDelete, path, nil, nil, nil); err != nil && !isNotFound(err) {
			return "", err
		}
		return item.TargetIdentity, nil
	default:
		return "", fmt.Errorf("unsupported milestone action %q", item.Action)
	}
	return idString(response), nil
}

func (p *Provider) applyIssue(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	body := map[string]any{
		"title": canonical.StringField(record.Fields, "title"), "body": managedBody(record, item),
		"state": canonical.StringField(record.Fields, "state"), "labels": stringSlice(record.Fields["labels"]),
	}
	if reason := canonical.StringField(record.Fields, "state_reason"); reason != "" {
		body["state_reason"] = reason
	}
	if milestone := canonical.StringField(record.Fields, "milestone"); milestone != "" {
		number, err := p.milestoneNumberByTitle(milestone)
		if err != nil {
			return "", err
		}
		body["milestone"] = number
	} else {
		body["milestone"] = nil
	}
	var response map[string]any
	switch item.Action {
	case reposync.ActionCreate:
		if err := p.client.doJSON(ctx, http.MethodPost, p.repoPath()+"/issues", nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionUpdate, reposync.ActionDelete:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		if item.Action == reposync.ActionDelete {
			body = map[string]any{"state": "closed", "state_reason": "not_planned"}
		}
		path := fmt.Sprintf("%s/issues/%d", p.repoPath(), canonical.IntField(target.Fields, "_number"))
		if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, body, &response); err != nil {
			return "", err
		}
	default:
		return "", fmt.Errorf("unsupported issue action %q", item.Action)
	}
	if item.Action == reposync.ActionDelete {
		return idString(response), nil
	}
	number := intValue(response, "number")
	lockMethod := http.MethodDelete
	var lockBody any
	if canonical.BoolField(record.Fields, "locked") {
		lockMethod, lockBody = http.MethodPut, map[string]any{}
	}
	if err := p.client.doJSON(ctx, lockMethod, fmt.Sprintf("%s/issues/%d/lock", p.repoPath(), number), nil, lockBody, nil); err != nil && !isNotFound(err) {
		if item.Action == reposync.ActionCreate {
			return "", fmt.Errorf("%w: GitHub Issue was created but lock state failed: %v", reposync.ErrResultUnknown, err)
		}
		return "", err
	}
	return idString(response), nil
}

func (p *Provider) applyIssueComment(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	body := map[string]any{"body": managedBody(record, item)}
	var response map[string]any
	switch item.Action {
	case reposync.ActionCreate:
		parentType := record.ParentType
		if parentType == "" {
			parentType = canonical.TypeIssue
		}
		number, err := p.targetParentNumber(record, parentType)
		if err != nil {
			return "", err
		}
		if err := p.client.doJSON(ctx, http.MethodPost, fmt.Sprintf("%s/issues/%d/comments", p.repoPath(), number), nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionUpdate:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/issues/comments/%d", p.repoPath(), canonical.IntField(target.Fields, "_database_id"))
		if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionDelete:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/issues/comments/%d", p.repoPath(), canonical.IntField(target.Fields, "_database_id"))
		if err := p.client.doJSON(ctx, http.MethodDelete, path, nil, nil, nil); err != nil && !isNotFound(err) {
			return "", err
		}
		return item.TargetIdentity, nil
	default:
		return "", fmt.Errorf("unsupported issue comment action %q", item.Action)
	}
	return idString(response), nil
}

func (p *Provider) applyPullRequest(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	if item.Action == reposync.ActionCreate && canonical.BoolField(record.Fields, "merged") {
		return p.applyPullRequestCarrier(ctx, item, record)
	}
	if item.Action != reposync.ActionCreate {
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		if canonical.StringField(target.Fields, "_carrier") == "issue" {
			return p.applyPullRequestCarrier(ctx, item, record)
		}
	}
	body := map[string]any{"title": canonical.StringField(record.Fields, "title"), "body": managedBody(record, item), "base": canonical.StringField(record.Fields, "base_ref")}
	var response map[string]any
	switch item.Action {
	case reposync.ActionCreate:
		body["draft"] = canonical.BoolField(record.Fields, "draft")
		head := canonical.StringField(record.Fields, "head_ref")
		if repo := canonical.StringField(record.Fields, "_head_repo"); record.SourceProvider == "github" && repo != "" && repo != p.endpoint.RepositoryFullName {
			head = strings.Split(repo, "/")[0] + ":" + head
		}
		body["head"] = head
		if err := p.client.doJSON(ctx, http.MethodPost, p.repoPath()+"/pulls", nil, body, &response); err != nil {
			var apiErr *APIError
			if canonical.StringField(record.Fields, "state") == "closed" && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnprocessableEntity {
				return p.applyPullRequestCarrier(ctx, item, record)
			}
			return "", err
		}
		if canonical.StringField(record.Fields, "state") == "closed" && !canonical.BoolField(record.Fields, "merged") {
			path := fmt.Sprintf("%s/pulls/%d", p.repoPath(), intValue(response, "number"))
			if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, map[string]any{"state": "closed"}, &response); err != nil {
				return "", fmt.Errorf("%w: GitHub pull request was created but could not be closed: %v", reposync.ErrResultUnknown, err)
			}
		}
	case reposync.ActionUpdate, reposync.ActionDelete:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		body["state"] = canonical.StringField(record.Fields, "state")
		if item.Action == reposync.ActionDelete {
			body = map[string]any{"state": "closed"}
		}
		path := fmt.Sprintf("%s/pulls/%d", p.repoPath(), canonical.IntField(target.Fields, "_number"))
		if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, body, &response); err != nil {
			return "", err
		}
		if item.Action == reposync.ActionUpdate && canonical.BoolField(target.Fields, "draft") != canonical.BoolField(record.Fields, "draft") {
			if err := p.setPullRequestDraft(ctx, target.Identity, canonical.BoolField(record.Fields, "draft")); err != nil {
				return "", err
			}
		}
	default:
		return "", fmt.Errorf("unsupported pull request action %q", item.Action)
	}
	if item.Action == reposync.ActionDelete {
		return idString(response), nil
	}
	if err := p.applyPullRequestRelations(ctx, response, record); err != nil {
		if item.Action == reposync.ActionCreate {
			return "", fmt.Errorf("%w: GitHub pull request was created but relations failed: %v", reposync.ErrResultUnknown, err)
		}
		return "", err
	}
	return idString(response), nil
}

func (p *Provider) setPullRequestDraft(ctx context.Context, nodeID string, draft bool) error {
	mutation := `mutation RepositorySyncDraft($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { pullRequest { id isDraft } } }`
	if draft {
		mutation = `mutation RepositorySyncDraft($id: ID!) { convertPullRequestToDraft(input: {pullRequestId: $id}) { pullRequest { id isDraft } } }`
	}
	return p.client.doGraphQL(ctx, mutation, map[string]any{"id": nodeID}, nil)
}

func (p *Provider) applyPullRequestCarrier(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	notice := "Historical pull request projected as an Issue carrier"
	if sha := canonical.StringField(record.Fields, "merge_commit_sha"); sha != "" {
		notice += " · Merge commit: `" + sha + "`"
	}
	body := map[string]any{
		"title":  canonical.StringField(record.Fields, "title"),
		"body":   managedBodyWithNotice(record, item, notice),
		"labels": stringSlice(record.Fields["labels"]),
	}
	if milestone := canonical.StringField(record.Fields, "milestone"); milestone != "" {
		number, err := p.milestoneNumberByTitle(milestone)
		if err != nil {
			return "", err
		}
		body["milestone"] = number
	} else {
		body["milestone"] = nil
	}
	var response map[string]any
	switch item.Action {
	case reposync.ActionCreate:
		createBody := make(map[string]any, len(body))
		for key, value := range body {
			createBody[key] = value
		}
		if err := p.client.doJSON(ctx, http.MethodPost, p.repoPath()+"/issues", nil, createBody, &response); err != nil {
			return "", err
		}
		if canonical.StringField(record.Fields, "state") == "closed" {
			path := fmt.Sprintf("%s/issues/%d", p.repoPath(), intValue(response, "number"))
			if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, map[string]any{"state": "closed"}, &response); err != nil {
				return "", fmt.Errorf("%w: GitHub pull request carrier was created but could not be closed: %v", reposync.ErrResultUnknown, err)
			}
		}
	case reposync.ActionUpdate, reposync.ActionDelete:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		if item.Action == reposync.ActionDelete {
			body = map[string]any{"state": "closed"}
		} else {
			body["state"] = canonical.StringField(record.Fields, "state")
		}
		path := fmt.Sprintf("%s/issues/%d", p.repoPath(), canonical.IntField(target.Fields, "_number"))
		if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, body, &response); err != nil {
			return "", err
		}
	default:
		return "", fmt.Errorf("unsupported projected pull request action %q", item.Action)
	}
	return idString(response), nil
}

func (p *Provider) applyPullRequestReview(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	if item.Action == reposync.ActionCreate && p.parentUsesIssueCarrier(record) {
		state := canonical.StringField(record.Fields, "_original_state")
		return p.applyProjectedConversationCarrier(ctx, item, record, "Historical review projected to the pull request conversation · Original state: "+state)
	}
	if item.Action != reposync.ActionCreate {
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		if canonical.StringField(target.Fields, "_carrier") == "issue_comment" {
			return p.applyProjectedConversationCarrier(ctx, item, record, "Historical review projected to the pull request conversation")
		}
	}
	if item.Action != reposync.ActionCreate {
		return "", fmt.Errorf("GitHub reviews are immutable; only attributed create is supported")
	}
	number, err := p.targetParentNumber(record, canonical.TypePullRequest)
	if err != nil {
		return "", err
	}
	body := map[string]any{"body": managedBody(record, item), "event": "COMMENT"}
	if commit := canonical.StringField(record.Fields, "commit_id"); commit != "" {
		body["commit_id"] = commit
	}
	var response map[string]any
	if err := p.client.doJSON(ctx, http.MethodPost, fmt.Sprintf("%s/pulls/%d/reviews", p.repoPath(), number), nil, body, &response); err != nil {
		return "", err
	}
	return idString(response), nil
}

func (p *Provider) applyReviewRequest(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	// Reviewer identities are provider-scoped. Project the requested names as
	// an auditable conversation note unless an explicit account-map feature is
	// added; sending the raw logins could notify or gate the wrong people.
	notice := "Review request projected to the pull request conversation"
	return p.applyProjectedConversationCarrier(ctx, item, record, notice)
}

func (p *Provider) applyPullRequestReviewComment(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	if item.Action == reposync.ActionCreate && p.parentUsesIssueCarrier(record) {
		return p.applyProjectedConversationCarrier(ctx, item, record, "Inline review comment projected to the pull request conversation")
	}
	if item.Action != reposync.ActionCreate && p.targetUsesIssueCommentCarrier(item) {
		return p.applyProjectedConversationCarrier(ctx, item, record, "Inline review comment projected to the pull request conversation")
	}
	body := map[string]any{"body": managedBody(record, item)}
	var response map[string]any
	switch item.Action {
	case reposync.ActionCreate:
		number, err := p.targetParentNumber(record, canonical.TypePullRequest)
		if err != nil {
			return "", err
		}
		for _, key := range []string{"commit_id", "path", "side", "start_side"} {
			if value := canonical.StringField(record.Fields, key); value != "" {
				body[key] = value
			}
		}
		for _, key := range []string{"line", "start_line"} {
			if value := canonical.IntField(record.Fields, key); value > 0 {
				body[key] = value
			}
		}
		path := fmt.Sprintf("%s/pulls/%d/comments", p.repoPath(), number)
		if err := p.client.doJSON(ctx, http.MethodPost, path, nil, body, &response); err != nil {
			var apiErr *APIError
			if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnprocessableEntity {
				return p.applyProjectedConversationCarrier(ctx, item, record, "Inline review comment could not be located and was projected to the pull request conversation")
			}
			return "", err
		}
	case reposync.ActionUpdate:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/pulls/comments/%d", p.repoPath(), canonical.IntField(target.Fields, "_database_id"))
		if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionDelete:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/pulls/comments/%d", p.repoPath(), canonical.IntField(target.Fields, "_database_id"))
		if err := p.client.doJSON(ctx, http.MethodDelete, path, nil, nil, nil); err != nil && !isNotFound(err) {
			return "", err
		}
		return item.TargetIdentity, nil
	default:
		return "", fmt.Errorf("unsupported review comment action %q", item.Action)
	}
	return idString(response), nil
}

func (p *Provider) parentUsesIssueCarrier(record canonical.Record) bool {
	if record.ParentIdentity == "" {
		return false
	}
	object, ok := p.cached(canonical.TypePullRequest, record.ParentIdentity)
	if !ok {
		return false
	}
	parent, err := canonical.Decode(object.Payload)
	return err == nil && canonical.StringField(parent.Fields, "_carrier") == "issue"
}

func (p *Provider) targetUsesIssueCommentCarrier(item reposync.PlannedItem) bool {
	if item.TargetIdentity == "" {
		return false
	}
	target, err := p.targetRecord(item)
	return err == nil && canonical.StringField(target.Fields, "_carrier") == "issue_comment"
}

func (p *Provider) applyProjectedConversationCarrier(ctx context.Context, item reposync.PlannedItem, record canonical.Record, notice string) (string, error) {
	body := map[string]any{"body": managedBodyWithNotice(record, item, notice)}
	var response map[string]any
	switch item.Action {
	case reposync.ActionCreate:
		number, err := p.targetParentNumber(record, canonical.TypePullRequest)
		if err != nil {
			return "", err
		}
		if err := p.client.doJSON(ctx, http.MethodPost, fmt.Sprintf("%s/issues/%d/comments", p.repoPath(), number), nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionUpdate:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/issues/comments/%d", p.repoPath(), canonical.IntField(target.Fields, "_database_id"))
		if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionDelete:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/issues/comments/%d", p.repoPath(), canonical.IntField(target.Fields, "_database_id"))
		if err := p.client.doJSON(ctx, http.MethodDelete, path, nil, nil, nil); err != nil && !isNotFound(err) {
			return "", err
		}
		return item.TargetIdentity, nil
	default:
		return "", fmt.Errorf("unsupported projected conversation action %q", item.Action)
	}
	return idString(response), nil
}

func (p *Provider) applyRelease(ctx context.Context, item reposync.PlannedItem, record canonical.Record) (string, error) {
	body := map[string]any{"tag_name": canonical.StringField(record.Fields, "tag_name"), "name": canonical.StringField(record.Fields, "name"), "body": managedBody(record, item), "draft": canonical.BoolField(record.Fields, "draft"), "prerelease": canonical.BoolField(record.Fields, "prerelease")}
	if target := canonical.StringField(record.Fields, "_target_commitish"); target != "" {
		body["target_commitish"] = target
	}
	var response map[string]any
	switch item.Action {
	case reposync.ActionCreate:
		if err := p.client.doJSON(ctx, http.MethodPost, p.repoPath()+"/releases", nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionUpdate:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/releases/%d", p.repoPath(), canonical.IntField(target.Fields, "_database_id"))
		if err := p.client.doJSON(ctx, http.MethodPatch, path, nil, body, &response); err != nil {
			return "", err
		}
	case reposync.ActionDelete:
		target, err := p.targetRecord(item)
		if err != nil {
			return "", err
		}
		path := fmt.Sprintf("%s/releases/%d", p.repoPath(), canonical.IntField(target.Fields, "_database_id"))
		if err := p.client.doJSON(ctx, http.MethodDelete, path, nil, nil, nil); err != nil && !isNotFound(err) {
			return "", err
		}
		return item.TargetIdentity, nil
	default:
		return "", fmt.Errorf("unsupported release action %q", item.Action)
	}
	return idString(response), nil
}

func (p *Provider) applyReleaseAsset(ctx context.Context, item reposync.PlannedItem, record canonical.Record, source reposync.Provider) (string, error) {
	if item.Action == reposync.ActionDelete || item.Action == reposync.ActionUpdate {
		if item.TargetIdentity != "" {
			target, err := p.targetRecord(item)
			if err != nil && item.Action == reposync.ActionDelete {
				return "", err
			}
			if err == nil {
				path := fmt.Sprintf("%s/releases/assets/%d", p.repoPath(), canonical.IntField(target.Fields, "_database_id"))
				if err := p.client.doJSON(ctx, http.MethodDelete, path, nil, nil, nil); err != nil && !isNotFound(err) {
					return "", err
				}
			}
		}
		if item.Action == reposync.ActionDelete {
			return item.TargetIdentity, nil
		}
	}
	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()
	temp, err := os.CreateTemp("", "ags-sync-release-asset-*")
	if err != nil {
		return "", err
	}
	tempPath := temp.Name()
	defer os.Remove(tempPath)
	defer temp.Close()
	hash := sha256.New()
	written, err := io.Copy(io.MultiWriter(temp, hash), io.LimitReader(reader, size+1))
	if err != nil {
		return "", err
	}
	if written != size || hex.EncodeToString(hash.Sum(nil)) != checksum {
		return "", fmt.Errorf("release asset source checksum mismatch")
	}
	if _, err := temp.Seek(0, io.SeekStart); err != nil {
		return "", err
	}
	tag := canonical.StringField(record.Fields, "release_tag")
	var release map[string]any
	if err := p.client.doJSON(ctx, http.MethodGet, p.repoPath()+"/releases/tags/"+url.PathEscape(tag), nil, nil, &release); err != nil {
		return "", err
	}
	var response map[string]any
	if err := p.client.uploadAsset(ctx, stringValue(release, "upload_url"), canonical.StringField(record.Fields, "name"), canonical.StringField(record.Fields, "label"), canonical.StringField(record.Fields, "content_type"), temp, size, &response); err != nil {
		return "", err
	}
	return idString(response), 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
	}
	endpoint := canonical.StringField(record.Fields, "_download_url")
	body, _, contentLength, err := p.client.openDownload(ctx, endpoint, p.maxAssetBytes)
	if err != nil {
		return nil, 0, "", err
	}
	size := int64(canonical.IntField(record.Fields, "size"))
	if contentLength >= 0 && contentLength != size {
		_ = body.Close()
		return nil, 0, "", fmt.Errorf("GitHub release asset size changed after inventory")
	}
	return body, size, canonical.StringField(record.Fields, "sha256"), nil
}

func (p *Provider) refreshType(ctx context.Context, objectType string) error {
	var (
		objects []reposync.InventoryObject
		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)
		if err == nil {
			var issueObjects []reposync.InventoryObject
			issueObjects, err = p.inventoryIssues(ctx)
			for _, object := range issueObjects {
				if object.ObjectType == canonical.TypePullRequest {
					objects = append(objects, object)
				}
			}
		}
	case canonical.TypePullRequestReview:
		objects, err = p.inventoryPullRequestReviews(ctx)
		if err == nil {
			objects, err = p.appendProjectedConversationInventory(ctx, objects, canonical.TypePullRequestReview)
		}
	case canonical.TypeReviewRequest:
		objects, err = p.inventoryReviewRequests(ctx)
		if err == nil {
			objects, err = p.appendProjectedConversationInventory(ctx, objects, canonical.TypeReviewRequest)
		}
	case canonical.TypePullRequestReviewComment:
		objects, err = p.inventoryPullRequestReviewComments(ctx)
		if err == nil {
			objects, err = p.appendProjectedConversationInventory(ctx, objects, canonical.TypePullRequestReviewComment)
		}
	case canonical.TypeRelease, canonical.TypeReleaseAsset:
		objects, err = p.inventoryReleases(ctx)
	default:
		return fmt.Errorf("unsupported GitHub inventory type %q", objectType)
	}
	if err != nil {
		return err
	}
	p.mu.Lock()
	for key, object := range p.inventoryByIdentity {
		if object.ObjectType == objectType || (objectType == canonical.TypeReleaseAsset && object.ObjectType == canonical.TypeRelease) || (objectType == canonical.TypeRelease && object.ObjectType == canonical.TypeReleaseAsset) {
			delete(p.inventoryByIdentity, key)
		}
	}
	for _, object := range objects {
		p.inventoryByIdentity[identityKey(object.ObjectType, object.Identity)] = object
	}
	p.mu.Unlock()
	return nil
}

func (p *Provider) appendProjectedConversationInventory(ctx context.Context, objects []reposync.InventoryObject, objectType string) ([]reposync.InventoryObject, error) {
	comments, err := p.inventoryIssueComments(ctx)
	if err != nil {
		return nil, err
	}
	for _, object := range comments {
		if object.ObjectType == objectType {
			objects = append(objects, object)
		}
	}
	return objects, 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) targetRecord(item reposync.PlannedItem) (canonical.Record, error) {
	object, ok := p.cached(item.ObjectType, item.TargetIdentity)
	if !ok {
		return canonical.Record{}, reposync.ErrObjectNotFound
	}
	return canonical.Decode(object.Payload)
}

func (p *Provider) targetParentNumber(record canonical.Record, objectType string) (int, error) {
	if record.ParentIdentity != "" {
		if object, ok := p.cached(objectType, record.ParentIdentity); ok {
			target, err := canonical.Decode(object.Payload)
			if err != nil {
				return 0, err
			}
			if number := canonical.IntField(target.Fields, "_number"); number > 0 {
				return number, nil
			}
		}
	}
	key := "_issue_number"
	if objectType == canonical.TypePullRequest {
		key = "_pull_number"
	}
	if number := canonical.IntField(record.Fields, key); number > 0 {
		return number, nil
	}
	return 0, fmt.Errorf("target parent identity is not resolved")
}

func (p *Provider) milestoneNumberByTitle(title string) (int, error) {
	p.mu.RLock()
	defer p.mu.RUnlock()
	for _, object := range p.inventoryByIdentity {
		if object.ObjectType != canonical.TypeMilestone {
			continue
		}
		record, err := canonical.Decode(object.Payload)
		if err != nil {
			continue
		}
		if strings.EqualFold(canonical.StringField(record.Fields, "title"), title) {
			if number := canonical.IntField(record.Fields, "_number"); number > 0 {
				return number, nil
			}
		}
	}
	return 0, fmt.Errorf("target milestone %q is not resolved", title)
}

func (p *Provider) applyPullRequestRelations(ctx context.Context, response map[string]any, record canonical.Record) error {
	number := intValue(response, "number")
	if number == 0 {
		return fmt.Errorf("GitHub pull request response has no number")
	}
	body := map[string]any{"labels": stringSlice(record.Fields["labels"])}
	if milestone := canonical.StringField(record.Fields, "milestone"); milestone != "" {
		value, err := p.milestoneNumberByTitle(milestone)
		if err != nil {
			return err
		}
		body["milestone"] = value
	} else {
		body["milestone"] = nil
	}
	return p.client.doJSON(ctx, http.MethodPatch, fmt.Sprintf("%s/issues/%d", p.repoPath(), number), nil, body, nil)
}

func managedBody(record canonical.Record, item reposync.PlannedItem) string {
	body := canonical.StringField(record.Fields, "body")
	if item.ObjectLinkID == nil {
		return body
	}
	return canonical.RenderManagedBody(body, record.SourceURL, record.OriginalActor, record.OriginalTime, *item.ObjectLinkID)
}

func managedBodyWithNotice(record canonical.Record, item reposync.PlannedItem, notice string) string {
	body := canonical.StringField(record.Fields, "body")
	if item.ObjectLinkID == nil {
		return body
	}
	return canonical.RenderManagedBodyWithNotice(body, record.SourceURL, record.OriginalActor, record.OriginalTime, *item.ObjectLinkID, notice)
}

func stringSlice(value any) []string {
	items, _ := value.([]any)
	result := make([]string, 0, len(items))
	for _, item := range items {
		if text, ok := item.(string); ok {
			result = append(result, text)
		}
	}
	if typed, ok := value.([]string); ok {
		return append([]string(nil), typed...)
	}
	return result
}

func classifyApplyError(err error) string {
	var apiErr *APIError
	if errors.As(err, &apiErr) {
		switch apiErr.StatusCode {
		case http.StatusForbidden:
			if apiErr.RetryAfter > 0 {
				return "rate_limited"
			}
			return "permission_denied"
		case http.StatusUnauthorized:
			return "permission_denied"
		case http.StatusConflict, http.StatusUnprocessableEntity:
			return "provider_conflict"
		case http.StatusTooManyRequests:
			return "rate_limited"
		default:
			return "github_api_error"
		}
	}
	return "apply_failed"
}

var _ = json.RawMessage{}
var _ = sync.Mutex{}

func itemAction(items []reposync.PlannedItem) string {
	if len(items) == 0 {
		return ""
	}
	return items[0].Action
}
