package reposync

import (
	"errors"
	"testing"
)

func TestPlanBootstrapTreatsCompleteSourceInventoryAsCreates(t *testing.T) {
	source := []InventoryObject{
		{ObjectType: "issues", Identity: "10", ObjectKey: "issue:10", UniqueKey: "10", ContentHash: "a", PayloadBytes: 40},
		{ObjectType: "labels", Identity: "20", ObjectKey: "label:bug", UniqueKey: "bug", ContentHash: "b", PayloadBytes: 20},
	}
	items, err := PlanBootstrap(source, nil)
	if err != nil {
		t.Fatalf("PlanBootstrap: %v", err)
	}
	if len(items) != len(source) {
		t.Fatalf("items = %d, want %d", len(items), len(source))
	}
	for _, item := range items {
		if item.Action != ActionCreate {
			t.Fatalf("item action = %q, want create", item.Action)
		}
	}
}

func TestPlanBootstrapRejectsNonEmptyTarget(t *testing.T) {
	_, err := PlanBootstrap(
		[]InventoryObject{{ObjectType: "labels", Identity: "1", ObjectKey: "label:bug"}},
		[]InventoryObject{{ObjectType: "labels", Identity: "2", ObjectKey: "label:existing"}},
	)
	if !errors.Is(err, ErrTargetNotEmpty) {
		t.Fatalf("error = %v, want ErrTargetNotEmpty", err)
	}
}

func TestSelectCheckpointCandidatesSkipsVerifiedAndKeepsChangedNewAndMissing(t *testing.T) {
	source := []InventoryObject{
		{ObjectType: "issues", Identity: "same", ObjectKey: "issue:same", Revision: "r1", ContentHash: "h1"},
		{ObjectType: "issues", Identity: "changed", ObjectKey: "issue:changed", Revision: "r2", ContentHash: "h2"},
		{ObjectType: "issues", Identity: "new", ObjectKey: "issue:new", Revision: "r1", ContentHash: "h1"},
	}
	links := []LinkView{
		{ID: 1, ObjectType: "issues", SourceIdentity: "same", TargetIdentity: "t1", SourceRevision: "r1", SourceHash: "h1", MappingVersion: "v1", Status: "active"},
		{ID: 2, ObjectType: "issues", SourceIdentity: "changed", TargetIdentity: "t2", SourceRevision: "r1", SourceHash: "old", MappingVersion: "v1", Status: "active"},
		{ID: 3, ObjectType: "issues", SourceIdentity: "missing", TargetIdentity: "t3", SourceRevision: "r1", SourceHash: "h3", MappingVersion: "v1", Status: "active"},
	}
	candidates, candidateLinks, skipped, err := SelectCheckpointCandidates(source, links, "v1", false)
	if err != nil {
		t.Fatal(err)
	}
	if skipped != 1 || len(candidates) != 2 || len(candidateLinks) != 2 {
		t.Fatalf("candidates=%#v links=%#v skipped=%d", candidates, candidateLinks, skipped)
	}
	if candidates[0].Identity != "changed" || candidates[1].Identity != "new" {
		t.Fatalf("unexpected candidates: %#v", candidates)
	}
	if candidateLinks[0].ID != 2 || candidateLinks[1].ID != 3 {
		t.Fatalf("unexpected candidate links: %#v", candidateLinks)
	}
}

func TestSelectCheckpointCandidatesFullAndMapperChange(t *testing.T) {
	source := []InventoryObject{{ObjectType: "labels", Identity: "1", ObjectKey: "label:bug", Revision: "r1", ContentHash: "h1"}}
	links := []LinkView{{ID: 1, ObjectType: "labels", SourceIdentity: "1", TargetIdentity: "2", SourceRevision: "r1", SourceHash: "h1", MappingVersion: "old", Status: "active"}}
	candidates, _, skipped, err := SelectCheckpointCandidates(source, links, "new", false)
	if err != nil || len(candidates) != 1 || skipped != 0 {
		t.Fatalf("mapper-change candidates=%#v skipped=%d err=%v", candidates, skipped, err)
	}
	candidates, _, skipped, err = SelectCheckpointCandidates(source, []LinkView{{ID: 1, ObjectType: "labels", SourceIdentity: "1", TargetIdentity: "2", SourceRevision: "r1", SourceHash: "h1", MappingVersion: "new", Status: "active"}}, "new", true)
	if err != nil || len(candidates) != 1 || skipped != 0 {
		t.Fatalf("full candidates=%#v skipped=%d err=%v", candidates, skipped, err)
	}
}

func TestPlanCurrentStateUsesLinksAndPreservesUnmanagedTarget(t *testing.T) {
	source := []InventoryObject{
		{ObjectType: "issues", Identity: "s1", ObjectKey: "issue:1", UniqueKey: "1", ContentHash: "new"},
		{ObjectType: "labels", Identity: "s2", ObjectKey: "label:bug", UniqueKey: "bug", ContentHash: "same"},
		{ObjectType: "milestones", Identity: "s3", ObjectKey: "milestone:v2", UniqueKey: "v2", ContentHash: "create"},
	}
	target := []InventoryObject{
		{ObjectType: "issues", Identity: "t1", ObjectKey: "issue:1", UniqueKey: "1", ContentHash: "old"},
		{ObjectType: "labels", Identity: "t2", ObjectKey: "label:bug", UniqueKey: "bug", ContentHash: "same"},
		{ObjectType: "releases", Identity: "unmanaged", ObjectKey: "release:v1", UniqueKey: "v1", ContentHash: "keep"},
		{ObjectType: "issues", Identity: "deleted-target", ObjectKey: "issue:9", UniqueKey: "9", ContentHash: "old"},
	}
	links := []LinkView{
		{ID: 1, ObjectType: "issues", SourceIdentity: "s1", TargetIdentity: "t1"},
		{ID: 2, ObjectType: "labels", SourceIdentity: "s2", TargetIdentity: "t2"},
		{ID: 3, ObjectType: "issues", SourceIdentity: "missing", TargetIdentity: "deleted-target"},
	}
	items, err := PlanCurrentState(source, target, links)
	if err != nil {
		t.Fatalf("PlanCurrentState: %v", err)
	}
	counts := map[string]int{}
	for _, item := range items {
		counts[item.Action]++
	}
	for action, want := range map[string]int{ActionCreate: 1, ActionUpdate: 1, ActionNoop: 1, ActionDelete: 1, ActionReport: 1} {
		if counts[action] != want {
			t.Fatalf("%s count = %d, want %d; all=%#v", action, counts[action], want, items)
		}
	}
}

func TestPlanCurrentStateAdoptsUnlinkedLabelWithMatchingName(t *testing.T) {
	items, err := PlanCurrentState(
		[]InventoryObject{{ObjectType: "labels", Identity: "source", ObjectKey: "label:bug", UniqueKey: "bug", ContentHash: "new"}},
		[]InventoryObject{{ObjectType: "labels", Identity: "target", ObjectKey: "label:bug", UniqueKey: "bug", ContentHash: "old"}},
		nil,
	)
	if err != nil {
		t.Fatalf("PlanCurrentState: %v", err)
	}
	if len(items) != 1 || items[0].Action != ActionUpdate || items[0].TargetIdentity != "target" {
		t.Fatalf("items = %#v, want one adopted label update", items)
	}
	if items[0].Reason != ReasonAdoptUnlinkedLabel {
		t.Fatalf("reason = %q, want %q", items[0].Reason, ReasonAdoptUnlinkedLabel)
	}
}

func TestPlanCurrentStateLinksUnchangedUnlinkedLabel(t *testing.T) {
	items, err := PlanCurrentState(
		[]InventoryObject{{ObjectType: "labels", Identity: "source", ObjectKey: "label:bug", UniqueKey: "bug", Revision: "new", ContentHash: "same"}},
		[]InventoryObject{{ObjectType: "labels", Identity: "target", ObjectKey: "label:bug", UniqueKey: "bug", Revision: "old", ContentHash: "same"}},
		nil,
	)
	if err != nil {
		t.Fatalf("PlanCurrentState: %v", err)
	}
	if len(items) != 1 || items[0].Action != ActionNoop || items[0].TargetIdentity != "target" {
		t.Fatalf("items = %#v, want one adopted label noop", items)
	}
}

func TestPlanCurrentStateAdoptsOnlyIdenticalUnlinkedGitRef(t *testing.T) {
	items, err := PlanCurrentState(
		[]InventoryObject{{ObjectType: "git_refs", Identity: "refs/heads/main", ObjectKey: "ref:refs/heads/main", UniqueKey: "refs/heads/main", Revision: "abc", ContentHash: "abc"}},
		[]InventoryObject{{ObjectType: "git_refs", Identity: "refs/heads/main", ObjectKey: "ref:refs/heads/main", UniqueKey: "refs/heads/main", Revision: "abc", ContentHash: "abc"}},
		nil,
	)
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 1 || items[0].Action != ActionNoop || items[0].TargetIdentity != "refs/heads/main" {
		t.Fatalf("items=%#v", items)
	}
}

func TestPlanCurrentStateAdoptsVerifiedFastForwardUnlinkedGitRef(t *testing.T) {
	source := InventoryObject{ObjectType: "git_refs", Identity: "refs/heads/main", ObjectKey: "ref:refs/heads/main", UniqueKey: "refs/heads/main", Revision: "new", ContentHash: "new"}
	target := InventoryObject{ObjectType: "git_refs", Identity: "refs/heads/main", ObjectKey: "ref:refs/heads/main", UniqueKey: "refs/heads/main", Revision: "old", ContentHash: "old"}
	items, err := PlanCurrentStateWithOptions([]InventoryObject{source}, []InventoryObject{target}, nil, CurrentStatePlanOptions{
		FastForwardUnlinkedRefs: map[string]bool{"refs/heads/main": true},
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 1 || items[0].Action != ActionUpdate || items[0].TargetIdentity != target.Identity {
		t.Fatalf("items=%#v, want an adopted fast-forward update", items)
	}
	if items[0].Reason != "adopt unlinked fast-forward ref" || items[0].ExpectedTargetRevision != "old" {
		t.Fatalf("item=%#v", items[0])
	}
}

func TestPlanCurrentStateUsesExplicitConfirmationForDivergentUnlinkedGitRef(t *testing.T) {
	source := InventoryObject{ObjectType: "git_refs", Identity: "refs/heads/main", ObjectKey: "ref:refs/heads/main", UniqueKey: "refs/heads/main", Revision: "source", ContentHash: "source"}
	target := InventoryObject{ObjectType: "git_refs", Identity: "refs/heads/main", ObjectKey: "ref:refs/heads/main", UniqueKey: "refs/heads/main", Revision: "target", ContentHash: "target"}
	items, err := PlanCurrentStateWithOptions([]InventoryObject{source}, []InventoryObject{target}, nil, CurrentStatePlanOptions{
		ConfirmedUnlinkedRefs: map[string]bool{"refs/heads/main": true},
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 1 || items[0].Action != ActionUpdate || items[0].ExpectedTargetRevision != "target" {
		t.Fatalf("items=%#v, want a force-with-lease fenced update", items)
	}
	if items[0].Reason != "replace confirmed divergent target ref if unchanged" {
		t.Fatalf("reason=%q", items[0].Reason)
	}
}

func TestPlanCurrentStateDeleteCarriesTargetPayloadForProviderWriter(t *testing.T) {
	targetPayload := []byte(`{"schema":1,"type":"labels","identity":"t","key":"label:t","fields":{"name":"bug"}}`)
	items, err := PlanCurrentState(nil, []InventoryObject{{
		ObjectType: "labels", Identity: "t", ObjectKey: "label:t", ContentHash: "old",
		Payload: targetPayload, PayloadBytes: int64(len(targetPayload)), Representation: "native",
	}}, []LinkView{{ID: 1, ObjectType: "labels", SourceIdentity: "gone", TargetIdentity: "t"}})
	if err != nil {
		t.Fatalf("PlanCurrentState: %v", err)
	}
	if len(items) != 1 || items[0].Action != ActionDelete || items[0].SourceIdentity != "gone" || string(items[0].Payload) != string(targetPayload) {
		t.Fatalf("delete item = %#v, want target payload", items)
	}
}

func TestPlanCurrentStateTombstonesLinkWhenBothObjectsAreAbsent(t *testing.T) {
	items, err := PlanCurrentState(nil, nil, []LinkView{{
		ID: 12, ObjectType: "issues", LogicalKey: "issue:gone",
		SourceIdentity: "source-gone", TargetIdentity: "target-gone", Status: "active",
	}})
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 1 || items[0].Action != ActionDelete || items[0].ObjectLinkID == nil || *items[0].ObjectLinkID != 12 {
		t.Fatalf("items=%#v, want one logical delete that tombstones the link", items)
	}
	if items[0].Reason != "linked source and target objects are both absent" {
		t.Fatalf("reason=%q", items[0].Reason)
	}
}

func TestPlanCurrentStateRepairsMissingLinkedLabelWithMatchingName(t *testing.T) {
	items, err := PlanCurrentState(
		[]InventoryObject{{ObjectType: "labels", Identity: "s", ObjectKey: "label:s", UniqueKey: "bug", ContentHash: "new"}},
		[]InventoryObject{{ObjectType: "labels", Identity: "other", ObjectKey: "label:other", UniqueKey: "bug", ContentHash: "old"}},
		[]LinkView{{ID: 7, ObjectType: "labels", SourceIdentity: "s", TargetIdentity: "missing"}},
	)
	if err != nil {
		t.Fatalf("PlanCurrentState: %v", err)
	}
	if len(items) != 1 || items[0].Action != ActionUpdate || items[0].TargetIdentity != "other" || items[0].ObjectLinkID == nil || *items[0].ObjectLinkID != 7 {
		t.Fatalf("items = %#v, want linked label reconciliation", items)
	}
}

func TestPlanCurrentStateRecoversPendingLinkFromValidatedMarker(t *testing.T) {
	source := []InventoryObject{{ObjectType: "issues", Identity: "source-1", ObjectKey: "issue:source-1", ContentHash: "same"}}
	target := []InventoryObject{{ObjectType: "issues", Identity: "target-9", ObjectKey: "issue:target-9", ContentHash: "same", ManagedLinkID: 42}}
	items, err := PlanCurrentState(source, target, []LinkView{{ID: 42, ObjectType: "issues", SourceIdentity: "source-1"}})
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 1 || items[0].Action != ActionNoop || items[0].TargetIdentity != "target-9" {
		t.Fatalf("items = %#v", items)
	}
}

func TestPlanCurrentStateReopensProjectedCarrierFromTombstonedLink(t *testing.T) {
	source := InventoryObject{ObjectType: "issues", Identity: "source", ObjectKey: "issue:source", ContentHash: "open-hash"}
	target := InventoryObject{ObjectType: "issues", Identity: "carrier", ObjectKey: "issue:carrier", ContentHash: "closed-hash"}
	items, err := PlanCurrentState([]InventoryObject{source}, []InventoryObject{target}, []LinkView{{
		ID: 8, ObjectType: "issues", SourceIdentity: "source", TargetIdentity: "carrier", Status: "tombstoned",
	}})
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 1 || items[0].Action != ActionUpdate || items[0].TargetIdentity != "carrier" {
		t.Fatalf("items=%#v", items)
	}

	items, err = PlanCurrentState(nil, []InventoryObject{target}, []LinkView{{
		ID: 8, ObjectType: "issues", SourceIdentity: "source", TargetIdentity: "carrier", Status: "tombstoned",
	}})
	if err != nil {
		t.Fatal(err)
	}
	if len(items) != 0 {
		t.Fatalf("tombstoned carrier was deleted again: %#v", items)
	}
}

func TestPlanCurrentStateRejectsAmbiguousValidatedMarker(t *testing.T) {
	target := []InventoryObject{
		{ObjectType: "issues", Identity: "target-1", ObjectKey: "issue:1", ContentHash: "a", ManagedLinkID: 42},
		{ObjectType: "issues", Identity: "target-2", ObjectKey: "issue:2", ContentHash: "b", ManagedLinkID: 42},
	}
	if _, err := PlanCurrentState(nil, target, nil); err == nil {
		t.Fatal("duplicate managed marker was accepted")
	}
}

func TestBuildPhysicalBatchesUsesImporterLimitsAndCompatibility(t *testing.T) {
	items := []PlannedItem{
		{ObjectType: "issues", ObjectKey: "1", Action: ActionCreate, PayloadBytes: 6},
		{ObjectType: "issues", ObjectKey: "2", Action: ActionCreate, PayloadBytes: 6},
		{ObjectType: "issues", ObjectKey: "3", Action: ActionUpdate, PayloadBytes: 1},
		{ObjectType: "labels", ObjectKey: "bug", Action: ActionCreate, PayloadBytes: 1},
		{ObjectType: "issues", ObjectKey: "noop", Action: ActionNoop},
	}
	batches, err := BuildPhysicalBatches(items, BatchLimits{MaxItems: 10, MaxBytes: 10})
	if err != nil {
		t.Fatalf("BuildPhysicalBatches: %v", err)
	}
	if len(batches) != 4 {
		t.Fatalf("batches = %d, want 4: %#v", len(batches), batches)
	}
	for _, batch := range batches {
		if len(batch.Items) == 0 {
			t.Fatal("empty physical batch")
		}
		for _, item := range batch.Items {
			if item.Action == ActionNoop {
				t.Fatal("noop item was submitted to a physical writer batch")
			}
		}
	}
}
