// Package reposync contains provider-neutral repository synchronization
// planning primitives. Provider adapters inventory and map objects; this
// package decides which logical operations are necessary.
package reposync

import (
	"encoding/json"
	"errors"
	"fmt"
	"sort"
)

const (
	ActionNoop     = "noop"
	ActionCreate   = "create"
	ActionUpdate   = "update"
	ActionDelete   = "delete"
	ActionReport   = "report"
	ActionConflict = "conflict"

	// ReasonAdoptUnlinkedLabel identifies a same-name label that is safe to
	// reconcile during first sync setup. Label names are unique in both
	// providers, and labels do not carry an independent user-editable history;
	// the source of truth may therefore update the target fields while the
	// target retains its local database ID.
	ReasonAdoptUnlinkedLabel = "adopt unlinked label with matching normalized name"
)

var ErrTargetNotEmpty = errors.New("sync bootstrap target is not empty")

// InventoryObject is one canonical object returned by a provider adapter.
// Identity is provider-immutable. UniqueKey represents a provider-enforced
// collision domain such as a ref name, label name, or release tag.
type InventoryObject struct {
	ObjectType  string
	Identity    string
	ObjectKey   string
	UniqueKey   string
	Revision    string
	ContentHash string
	// SourceHash remains the authority-side canonical hash after mapping has
	// produced the target-facing ContentHash. Providers leave it blank; the
	// mapper fills it before transforming an object.
	SourceHash   string
	PayloadBytes int64
	// Payload is the canonical provider-neutral object used by the target
	// adapter. It is persisted in the RunItem intent so a reclaimed worker can
	// resume without depending on an in-memory batch or importer snapshot.
	Payload json.RawMessage
	// Representation records native/attributed/projected/archive_only.
	Representation string
	// ManagedLinkID is populated only after the provider adapter validates a
	// visible marker against the Binding's durable ObjectLink and endpoint
	// identity. The planner uses it to recover a create whose response was lost.
	ManagedLinkID uint
}

// LinkView is an endpoint-oriented view of one durable ObjectLink. Callers
// resolve side a/side b into source/target before invoking PlanCurrentState.
type LinkView struct {
	ID             uint
	ObjectType     string
	LogicalKey     string
	SourceIdentity string
	TargetIdentity string
	SourceRevision string
	SourceHash     string
	TargetRevision string
	TargetHash     string
	MappingVersion string
	Status         string
	// Metadata is the latest canonical authority record retained by the
	// ObjectLink. It lets projected carriers reconstruct fields their native
	// provider object cannot express without putting internal state in a body
	// marker.
	Metadata json.RawMessage
}

// CurrentStatePlanOptions carries facts that require a provider-specific
// read before planning. The planner remains conservative when a fact is
// absent: an unmanaged target object is never adopted merely because it has
// the same natural key.
type CurrentStatePlanOptions struct {
	// FastForwardUnlinkedRefs contains ref names for which the worker proved
	// that the target tip is an ancestor of the source tip. It permits a first
	// baseline to adopt an existing branch without treating a normal upstream
	// advance as a conflict. Divergent refs are deliberately omitted.
	FastForwardUnlinkedRefs map[string]bool
	// ConfirmedUnlinkedRefs are explicitly approved by a repository admin.
	// Apply still uses the target revision as a force-with-lease fence, so a
	// branch changed after comparison is never overwritten blindly.
	ConfirmedUnlinkedRefs map[string]bool
}

// SelectCheckpointCandidates reduces a normal run to objects whose authority
// revision changed, objects without a durable link, and active links whose
// authority object disappeared. A full audit deliberately returns every
// managed object, but still does not enumerate unrelated target-only data.
//
// A blank source revision is treated as unreliable and therefore never
// skipped. ContentHash is retained as a second fence because some providers
// expose coarse updated-at revisions.
func SelectCheckpointCandidates(source []InventoryObject, links []LinkView, mapperVersion string, full bool) ([]InventoryObject, []LinkView, int, error) {
	sourceByID, err := indexInventory(source)
	if err != nil {
		return nil, nil, 0, fmt.Errorf("source inventory: %w", err)
	}
	linkBySource := make(map[string]LinkView, len(links))
	for _, link := range links {
		if link.ObjectType == "" || link.SourceIdentity == "" {
			return nil, nil, 0, errors.New("object link requires object type and source identity")
		}
		key := inventoryTypeKey(link.ObjectType, link.SourceIdentity)
		if _, exists := linkBySource[key]; exists {
			return nil, nil, 0, fmt.Errorf("duplicate source object link %q", key)
		}
		linkBySource[key] = link
	}

	candidateSource := make([]InventoryObject, 0, len(source))
	candidateLinkByID := make(map[uint]LinkView)
	skipped := 0
	for _, object := range source {
		link, linked := linkBySource[inventoryTypeKey(object.ObjectType, object.Identity)]
		sourceHash := object.SourceHash
		if sourceHash == "" {
			sourceHash = object.ContentHash
		}
		changed := full || !linked || object.Revision == "" || link.MappingVersion != mapperVersion ||
			link.SourceRevision != object.Revision || link.SourceHash != sourceHash
		if changed {
			candidateSource = append(candidateSource, object)
			if linked {
				candidateLinkByID[link.ID] = link
			}
		} else {
			skipped++
		}
	}
	for _, link := range links {
		if link.Status == "tombstoned" {
			continue
		}
		if _, exists := sourceByID[inventoryTypeKey(link.ObjectType, link.SourceIdentity)]; !exists {
			candidateLinkByID[link.ID] = link
		}
	}
	candidateLinks := make([]LinkView, 0, len(candidateLinkByID))
	for _, link := range links {
		if _, exists := candidateLinkByID[link.ID]; exists {
			candidateLinks = append(candidateLinks, link)
		}
	}
	return candidateSource, candidateLinks, skipped, nil
}

// PlannedItem is the provider-neutral logical RunItem produced by a planner.
type PlannedItem struct {
	ObjectLinkID           *uint
	ObjectType             string
	ObjectKey              string
	Action                 string
	Reason                 string
	SourceIdentity         string
	TargetIdentity         string
	SourceRevision         string
	SourceHash             string
	DesiredHash            string
	ExpectedTargetRevision string
	ExpectedTargetHash     string
	PayloadBytes           int64
	DependencyPhase        int
	Payload                json.RawMessage
	Representation         string
}

// PlanBootstrap produces the empty-target fast path. The caller must first
// perform a complete lightweight target inventory; this guard prevents a stale
// emptiness decision from silently overwriting target data.
func PlanBootstrap(source, target []InventoryObject) ([]PlannedItem, error) {
	if len(target) != 0 {
		return nil, ErrTargetNotEmpty
	}
	items := make([]PlannedItem, 0, len(source))
	for _, object := range source {
		if err := validateInventoryObject(object); err != nil {
			return nil, err
		}
		if object.Representation == "archive_only" {
			items = append(items, archiveItem(object, nil))
		} else {
			items = append(items, createItem(object, nil))
		}
	}
	sortPlannedItems(items)
	return items, nil
}

// PlanCurrentState compares the complete current inventories. Only target
// objects owned by a LinkView can be updated or deleted; unmanaged target-only
// objects are preserved and reported.
func PlanCurrentState(source, target []InventoryObject, links []LinkView) ([]PlannedItem, error) {
	return PlanCurrentStateWithOptions(source, target, links, CurrentStatePlanOptions{})
}

// PlanCurrentStateWithOptions compares complete current inventories with
// verified provider-specific safety facts supplied by the caller.
func PlanCurrentStateWithOptions(source, target []InventoryObject, links []LinkView, options CurrentStatePlanOptions) ([]PlannedItem, error) {
	sourceByID, err := indexInventory(source)
	if err != nil {
		return nil, fmt.Errorf("source inventory: %w", err)
	}
	targetByID, err := indexInventory(target)
	if err != nil {
		return nil, fmt.Errorf("target inventory: %w", err)
	}
	targetByUnique := make(map[string]InventoryObject, len(target))
	targetByManagedLink := make(map[uint]InventoryObject)
	accountedTarget := make(map[string]bool, len(target))
	for _, object := range target {
		if object.ManagedLinkID != 0 {
			if previous, exists := targetByManagedLink[object.ManagedLinkID]; exists {
				return nil, fmt.Errorf("target inventory has duplicate managed marker %d on %s and %s", object.ManagedLinkID, previous.Identity, object.Identity)
			}
			targetByManagedLink[object.ManagedLinkID] = object
		}
		if object.UniqueKey == "" {
			continue
		}
		key := inventoryTypeKey(object.ObjectType, object.UniqueKey)
		if _, exists := targetByUnique[key]; exists {
			return nil, fmt.Errorf("target inventory has duplicate unique key %q", key)
		}
		targetByUnique[key] = object
	}

	linkBySource := make(map[string]LinkView, len(links))
	managedTarget := make(map[string]LinkView, len(links))
	for _, link := range links {
		if link.ObjectType == "" || link.SourceIdentity == "" {
			return nil, errors.New("object link requires object type and source identity")
		}
		sourceKey := inventoryTypeKey(link.ObjectType, link.SourceIdentity)
		if _, exists := linkBySource[sourceKey]; exists {
			return nil, fmt.Errorf("duplicate source object link %q", sourceKey)
		}
		linkBySource[sourceKey] = link
		if link.TargetIdentity != "" {
			targetKey := inventoryTypeKey(link.ObjectType, link.TargetIdentity)
			if _, exists := managedTarget[targetKey]; exists {
				return nil, fmt.Errorf("duplicate target object link %q", targetKey)
			}
			managedTarget[targetKey] = link
		}
	}

	items := make([]PlannedItem, 0, len(source)+len(target))
	for _, object := range source {
		sourceKey := inventoryTypeKey(object.ObjectType, object.Identity)
		link, linked := linkBySource[sourceKey]
		if object.Representation == "archive_only" {
			var linkID *uint
			if linked {
				value := link.ID
				linkID = &value
			}
			items = append(items, archiveItem(object, linkID))
			continue
		}
		if !linked {
			if collision, exists := targetByUnique[inventoryTypeKey(object.ObjectType, object.UniqueKey)]; object.UniqueKey != "" && exists {
				accountedTarget[inventoryTypeKey(collision.ObjectType, collision.Identity)] = true
				if object.ObjectType == "labels" {
					items = append(items, adoptUnlinkedLabelItem(object, collision, nil))
					continue
				}
				if (object.ObjectType == "git_refs" || object.ObjectType == "wiki_refs") && object.ContentHash == collision.ContentHash {
					items = append(items, PlannedItem{
						ObjectType: object.ObjectType, ObjectKey: object.ObjectKey, Action: ActionNoop,
						Reason: "adopt identical unlinked ref", SourceIdentity: object.Identity,
						TargetIdentity: collision.Identity, SourceRevision: object.Revision, SourceHash: inventorySourceHash(object),
						DesiredHash: object.ContentHash, ExpectedTargetRevision: collision.Revision,
						ExpectedTargetHash: collision.ContentHash, PayloadBytes: object.PayloadBytes,
						Payload: append(json.RawMessage(nil), object.Payload...), Representation: object.Representation,
					})
					continue
				}
				if object.ObjectType == "git_refs" && (options.FastForwardUnlinkedRefs[object.UniqueKey] || options.ConfirmedUnlinkedRefs[object.UniqueKey]) {
					reason := "adopt unlinked fast-forward ref"
					if options.ConfirmedUnlinkedRefs[object.UniqueKey] && !options.FastForwardUnlinkedRefs[object.UniqueKey] {
						reason = "replace confirmed divergent target ref if unchanged"
					}
					items = append(items, PlannedItem{
						ObjectType: object.ObjectType, ObjectKey: object.ObjectKey, Action: ActionUpdate,
						Reason: reason, SourceIdentity: object.Identity,
						TargetIdentity: collision.Identity, SourceRevision: object.Revision, SourceHash: inventorySourceHash(object),
						DesiredHash: object.ContentHash, ExpectedTargetRevision: collision.Revision,
						ExpectedTargetHash: collision.ContentHash, PayloadBytes: object.PayloadBytes,
						Payload: append(json.RawMessage(nil), object.Payload...), Representation: object.Representation,
					})
					continue
				}
				items = append(items, PlannedItem{
					ObjectType: object.ObjectType, ObjectKey: object.ObjectKey,
					Action: ActionConflict, Reason: "unmanaged target occupies the source unique key",
					SourceIdentity: object.Identity, TargetIdentity: collision.Identity,
					SourceRevision: object.Revision, SourceHash: inventorySourceHash(object), DesiredHash: object.ContentHash,
					PayloadBytes: object.PayloadBytes, Payload: append(json.RawMessage(nil), object.Payload...),
					Representation: object.Representation,
				})
				continue
			}
			items = append(items, createItem(object, nil))
			continue
		}

		linkID := link.ID
		targetIdentity := link.TargetIdentity
		recoveredByMarker := false
		if recovered, exists := targetByManagedLink[link.ID]; exists {
			if recovered.ObjectType != object.ObjectType {
				accountedTarget[inventoryTypeKey(recovered.ObjectType, recovered.Identity)] = true
				items = append(items, PlannedItem{
					ObjectLinkID: &linkID, ObjectType: object.ObjectType, ObjectKey: object.ObjectKey,
					Action: ActionConflict, Reason: "managed marker is attached to an object of the wrong resource type",
					SourceIdentity: object.Identity, TargetIdentity: recovered.Identity,
					SourceRevision: object.Revision, SourceHash: inventorySourceHash(object), DesiredHash: object.ContentHash,
					PayloadBytes: object.PayloadBytes, Payload: append(json.RawMessage(nil), object.Payload...), Representation: object.Representation,
				})
				continue
			}
			if targetIdentity == "" {
				targetIdentity = recovered.Identity
				recoveredByMarker = true
			}
		}
		if targetIdentity == "" {
			if collision, exists := targetByUnique[inventoryTypeKey(object.ObjectType, object.UniqueKey)]; object.UniqueKey != "" && exists {
				accountedTarget[inventoryTypeKey(collision.ObjectType, collision.Identity)] = true
				if object.ObjectType == "labels" {
					items = append(items, adoptUnlinkedLabelItem(object, collision, &linkID))
					continue
				}
				items = append(items, PlannedItem{
					ObjectLinkID: &linkID, ObjectType: object.ObjectType, ObjectKey: object.ObjectKey,
					Action: ActionConflict, Reason: "unmanaged target occupies the source unique key",
					SourceIdentity: object.Identity, TargetIdentity: collision.Identity,
					SourceRevision: object.Revision, SourceHash: inventorySourceHash(object), DesiredHash: object.ContentHash,
					PayloadBytes: object.PayloadBytes, Payload: append(json.RawMessage(nil), object.Payload...),
					Representation: object.Representation,
				})
				continue
			}
			items = append(items, createItem(object, &linkID))
			continue
		}
		targetObject, exists := targetByID[inventoryTypeKey(object.ObjectType, targetIdentity)]
		if !exists {
			if collision, occupied := targetByUnique[inventoryTypeKey(object.ObjectType, object.UniqueKey)]; object.UniqueKey != "" && occupied {
				accountedTarget[inventoryTypeKey(collision.ObjectType, collision.Identity)] = true
				if object.ObjectType == "labels" {
					items = append(items, adoptUnlinkedLabelItem(object, collision, &linkID))
					continue
				}
				items = append(items, PlannedItem{
					ObjectLinkID: &linkID, ObjectType: object.ObjectType, ObjectKey: object.ObjectKey,
					Action: ActionConflict, Reason: "linked target is missing and an unmanaged target occupies the source unique key",
					SourceIdentity: object.Identity, TargetIdentity: collision.Identity,
					SourceRevision: object.Revision, SourceHash: inventorySourceHash(object), DesiredHash: object.ContentHash,
					PayloadBytes: object.PayloadBytes, Payload: append(json.RawMessage(nil), object.Payload...),
					Representation: object.Representation,
				})
				continue
			}
			items = append(items, createItem(object, &linkID))
			continue
		}
		if recoveredByMarker {
			accountedTarget[inventoryTypeKey(targetObject.ObjectType, targetObject.Identity)] = true
		}
		item := PlannedItem{
			ObjectLinkID: &linkID, ObjectType: object.ObjectType, ObjectKey: object.ObjectKey,
			SourceIdentity: object.Identity, TargetIdentity: targetObject.Identity,
			SourceRevision: object.Revision, SourceHash: inventorySourceHash(object), DesiredHash: object.ContentHash,
			ExpectedTargetRevision: targetObject.Revision, ExpectedTargetHash: targetObject.ContentHash,
			PayloadBytes: object.PayloadBytes, Payload: append(json.RawMessage(nil), object.Payload...),
			Representation: object.Representation,
		}
		if object.ContentHash == targetObject.ContentHash {
			item.Action = ActionNoop
		} else {
			item.Action = ActionUpdate
		}
		items = append(items, item)
	}

	for _, link := range links {
		if link.Status == "tombstoned" {
			continue
		}
		if link.TargetIdentity == "" {
			continue
		}
		if _, exists := sourceByID[inventoryTypeKey(link.ObjectType, link.SourceIdentity)]; exists {
			continue
		}
		targetObject, exists := targetByID[inventoryTypeKey(link.ObjectType, link.TargetIdentity)]
		if !exists {
			linkID := link.ID
			items = append(items, PlannedItem{
				ObjectLinkID: &linkID, ObjectType: link.ObjectType, ObjectKey: link.LogicalKey,
				Action: ActionDelete, Reason: "linked source and target objects are both absent",
				SourceIdentity: link.SourceIdentity, TargetIdentity: link.TargetIdentity,
			})
			continue
		}
		linkID := link.ID
		items = append(items, PlannedItem{
			ObjectLinkID: &linkID, ObjectType: targetObject.ObjectType, ObjectKey: targetObject.ObjectKey,
			Action: ActionDelete, Reason: "linked source object is absent from complete inventory",
			SourceIdentity: link.SourceIdentity,
			TargetIdentity: targetObject.Identity, ExpectedTargetRevision: targetObject.Revision,
			ExpectedTargetHash: targetObject.ContentHash,
			PayloadBytes:       targetObject.PayloadBytes, Payload: append(json.RawMessage(nil), targetObject.Payload...),
			Representation: targetObject.Representation,
		})
	}

	for _, object := range target {
		if _, linked := managedTarget[inventoryTypeKey(object.ObjectType, object.Identity)]; linked {
			continue
		}
		if accountedTarget[inventoryTypeKey(object.ObjectType, object.Identity)] {
			continue
		}
		items = append(items, PlannedItem{
			ObjectType: object.ObjectType, ObjectKey: object.ObjectKey,
			Action: ActionReport, Reason: "preserve unmanaged target object",
			TargetIdentity: object.Identity, ExpectedTargetRevision: object.Revision,
			ExpectedTargetHash: object.ContentHash,
		})
	}

	sortPlannedItems(items)
	return items, nil
}

func createItem(object InventoryObject, linkID *uint) PlannedItem {
	sourceHash := inventorySourceHash(object)
	return PlannedItem{
		ObjectLinkID: linkID, ObjectType: object.ObjectType, ObjectKey: object.ObjectKey,
		Action: ActionCreate, SourceIdentity: object.Identity, SourceRevision: object.Revision, SourceHash: sourceHash,
		DesiredHash: object.ContentHash, PayloadBytes: object.PayloadBytes,
		Payload: append(json.RawMessage(nil), object.Payload...), Representation: object.Representation,
	}
}

// adoptUnlinkedLabelItem reconciles a target label that has the same
// case-insensitive name as the source label but predates this sync binding.
// The source remains authoritative for label fields; the target identity is
// retained so existing issue/PR label associations remain intact.
func adoptUnlinkedLabelItem(source, target InventoryObject, linkID *uint) PlannedItem {
	item := PlannedItem{
		ObjectLinkID: linkID, ObjectType: source.ObjectType, ObjectKey: source.ObjectKey,
		Action: ActionUpdate, Reason: ReasonAdoptUnlinkedLabel,
		SourceIdentity: source.Identity, TargetIdentity: target.Identity,
		SourceRevision: source.Revision, SourceHash: inventorySourceHash(source), DesiredHash: source.ContentHash,
		ExpectedTargetRevision: target.Revision, ExpectedTargetHash: target.ContentHash,
		PayloadBytes: source.PayloadBytes, Payload: append(json.RawMessage(nil), source.Payload...),
		Representation: source.Representation,
	}
	if source.ContentHash == target.ContentHash {
		item.Action = ActionNoop
	}
	return item
}

func archiveItem(object InventoryObject, linkID *uint) PlannedItem {
	sourceHash := inventorySourceHash(object)
	return PlannedItem{
		ObjectLinkID: linkID, ObjectType: object.ObjectType, ObjectKey: object.ObjectKey,
		Action: ActionReport, Reason: "target cannot faithfully replay this actor-owned object; preserve provenance metadata",
		SourceIdentity: object.Identity, SourceRevision: object.Revision, SourceHash: sourceHash, DesiredHash: object.ContentHash,
		PayloadBytes: object.PayloadBytes, Payload: append(json.RawMessage(nil), object.Payload...),
		Representation: "archive_only",
	}
}

func inventorySourceHash(object InventoryObject) string {
	if object.SourceHash != "" {
		return object.SourceHash
	}
	return object.ContentHash
}

func indexInventory(objects []InventoryObject) (map[string]InventoryObject, error) {
	indexed := make(map[string]InventoryObject, len(objects))
	for _, object := range objects {
		if err := validateInventoryObject(object); err != nil {
			return nil, err
		}
		key := inventoryTypeKey(object.ObjectType, object.Identity)
		if _, exists := indexed[key]; exists {
			return nil, fmt.Errorf("duplicate identity %q", key)
		}
		indexed[key] = object
	}
	return indexed, nil
}

func validateInventoryObject(object InventoryObject) error {
	if object.ObjectType == "" || object.Identity == "" || object.ObjectKey == "" {
		return errors.New("inventory object requires object type, identity, and object key")
	}
	if object.PayloadBytes < 0 {
		return errors.New("inventory object payload bytes cannot be negative")
	}
	return nil
}

func inventoryTypeKey(objectType, value string) string {
	return objectType + "\x00" + value
}

func sortPlannedItems(items []PlannedItem) {
	sort.SliceStable(items, func(i, j int) bool {
		if items[i].DependencyPhase != items[j].DependencyPhase {
			return items[i].DependencyPhase < items[j].DependencyPhase
		}
		if items[i].ObjectType != items[j].ObjectType {
			return items[i].ObjectType < items[j].ObjectType
		}
		if items[i].Action != items[j].Action {
			return items[i].Action < items[j].Action
		}
		return items[i].ObjectKey < items[j].ObjectKey
	})
}
