package pipeline

import (
	"context"
	"encoding/json"
	"fmt"
	"reflect"
	"strings"
	"testing"

	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)

func handoffFixture(t *testing.T, value codecNotes) (RunInput, PreparedPipelineStep, contracts.SerializedOutput) {
	t.Helper()
	prepared := preparedOrderedPipeline(t, 1,
		orderedLaneSpec{id: "notes", profile: "notes"},
		orderedLaneSpec{id: "score", profile: "score"},
	)
	consumer := &prepared.Steps[1].lanes[0]
	installGeneratedTarget := func(target *ResolvedReferenceTarget, stage ModuleStage, module string) {
		*target = ResolvedReferenceTarget{
			Stage:  stage,
			StepID: consumer.resolved.StepID,
			LaneID: consumer.resolved.ID,
			Module: module,
			Bindings: []ReferenceBinding{{
				Stage:    stage,
				LaneID:   consumer.resolved.ID,
				SlotName: "producer-output",
				Artifact: &ArtifactReference{Step: "step-1", Lane: "notes"},
			}},
			ReferenceSet: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
				"producer-output": {Slot: contracts.ReferenceSlot{
					Name:                  "producer-output",
					AcceptedArtifactKinds: []contracts.ArtifactKind{"test/notes"},
					AcceptedMediaTypes:    []string{"application/json"},
				}},
			}},
		}
	}
	installGeneratedTarget(&consumer.resolved.ExtractReferences, StageExtract, consumer.resolved.Extract.Module)
	installGeneratedTarget(&consumer.resolved.MergeReferences, StageMerge, consumer.resolved.Merge.Module)
	installGeneratedTarget(&consumer.resolved.NormalizeReferences, StageNormalize, consumer.resolved.Normalize.Module)

	artifact, err := checkpointArtifact(prepared.Steps[0].lanes[0].typed.codec, "notes", "typed/normalize", "source", value)
	if err != nil {
		t.Fatalf("checkpointArtifact() error = %v", err)
	}
	input := RunInput{Prepared: prepared}
	input.pipeline = prepared.resolved
	return input, prepared.Steps[1], contracts.SerializedOutput{
		StepID:        "step-1",
		LaneID:        "notes",
		NormalizerKey: "typed/normalize",
		SourceID:      "source",
		Artifact:      contracts.CloneSerializedArtifact(artifact.Artifact),
	}
}

func TestBuildStepReferenceSetsCanonicalizesAndClonesFanout(t *testing.T) {
	input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
	sets, provenance, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
	if err != nil {
		t.Fatalf("buildStepReferenceSets() error = %v", err)
	}
	if got, want := len(sets), 3; got != want {
		t.Fatalf("reference target set count = %d, want %d", got, want)
	}
	if got, want := len(provenance), 3; got != want {
		t.Fatalf("generated provenance count = %d, want %d", got, want)
	}

	var first []byte
	for key, set := range sets {
		item := set.Slots["producer-output"].Items[0]
		if item.Origin.Type != "generated" || item.Origin.URI != "" {
			t.Fatalf("target %v origin = %#v, want generated origin without URI", key, item.Origin)
		}
		if item.Producer.StepID != "step-1" || item.Producer.LaneID != "notes" || item.Producer.ModuleKey != "typed/normalize" {
			t.Fatalf("target %v producer = %#v, want producer provenance", key, item.Producer)
		}
		if first == nil {
			first = item.Content
			continue
		}
		if &first[0] == &item.Content[0] {
			t.Fatalf("target %v shares generated content backing storage", key)
		}
	}
	if got := len(step.lanes[0].resolved.ExtractReferences.ReferenceSet.Slots["producer-output"].Items); got != 0 {
		t.Fatalf("prepared extract reference items = %d, want zero", got)
	}

	encoded, err := json.Marshal(provenance)
	if err != nil {
		t.Fatalf("marshal generated provenance: %v", err)
	}
	text := string(encoded)
	for _, forbidden := range []string{"first", "file://", "content_base64"} {
		if strings.Contains(text, forbidden) {
			t.Fatalf("generated provenance contains forbidden %q: %s", forbidden, text)
		}
	}
	if !strings.Contains(text, "producer_pipeline_id") || !strings.Contains(text, "schema_digest") {
		t.Fatalf("generated provenance = %s, want bounded producer and schema identity", text)
	}
}

func TestBuildStepReferenceSetsIndexesManyOutputsForFanout(t *testing.T) {
	input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
	outputs := make([]contracts.SerializedOutput, 0, 65)
	for index := range 64 {
		outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("unrelated-step-%d", index), LaneID: "unrelated-lane"})
	}
	outputs = append(outputs, producerOutput)

	sets, provenance, err := buildStepReferenceSets(input, step, outputs)
	if err != nil {
		t.Fatalf("buildStepReferenceSets() error = %v", err)
	}
	if len(sets) != 3 || len(provenance) != 3 {
		t.Fatalf("reference sets/provenance = %d/%d, want 3/3", len(sets), len(provenance))
	}
	for target, set := range sets {
		item := set.Slots["producer-output"].Items[0]
		if item.Producer.StepID != "step-1" || item.Producer.LaneID != "notes" || string(item.Content) == "" {
			t.Fatalf("target %v item = %#v, want canonical producer artifact", target, item)
		}
	}
}

func TestGeneratedReferenceAcceptsTypedEmptyCollection(t *testing.T) {
	input, step, producerOutput := handoffFixture(t, codecNotes{})
	sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
	if err != nil {
		t.Fatalf("buildStepReferenceSets() error = %v, want nil for accepted empty collection", err)
	}
	if got := len(sets[keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)].Slots["producer-output"].Items); got != 1 {
		t.Fatalf("generated empty collection item count = %d, want one artifact", got)
	}
}

func TestGeneratedReferenceRejectsInvalidProducerOutputsDeterministically(t *testing.T) {
	tests := []struct {
		name    string
		outputs func(contracts.SerializedOutput) []contracts.SerializedOutput
		mutate  func(*ResolvedReferenceTarget, *contracts.SerializedOutput)
		want    string
	}{
		{name: "missing", outputs: func(contracts.SerializedOutput) []contracts.SerializedOutput { return nil }, want: "no accepted normalized output"},
		{name: "multiple", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
			return []contracts.SerializedOutput{output, output}
		}, want: "exactly one is required"},
		{name: "multiple after identifier normalization", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
			duplicate := contracts.CloneSerializedOutput(output)
			duplicate.StepID = " step-1 "
			duplicate.LaneID = " notes "
			return []contracts.SerializedOutput{output, duplicate}
		}, want: "producer has 2 accepted normalized outputs; exactly one is required"},
		{name: "kind mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
			return []contracts.SerializedOutput{output}
		}, mutate: func(target *ResolvedReferenceTarget, _ *contracts.SerializedOutput) {
			target.ReferenceSet.Slots["producer-output"] = contracts.ResolvedReferenceSlot{Slot: contracts.ReferenceSlot{Name: "producer-output", AcceptedArtifactKinds: []contracts.ArtifactKind{"test/score"}}}
		}, want: "does not accept artifact kind"},
		{name: "schema mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
			return []contracts.SerializedOutput{output}
		}, mutate: func(_ *ResolvedReferenceTarget, output *contracts.SerializedOutput) {
			output.Artifact.Schema.ID = "wrong.schema"
		}, want: "schema identity"},
		{name: "media mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
			return []contracts.SerializedOutput{output}
		}, mutate: func(_ *ResolvedReferenceTarget, output *contracts.SerializedOutput) {
			output.Artifact.MediaType = "text/plain"
		}, want: "media type"},
		{name: "size mismatch", outputs: func(output contracts.SerializedOutput) []contracts.SerializedOutput {
			return []contracts.SerializedOutput{output}
		}, mutate: func(target *ResolvedReferenceTarget, _ *contracts.SerializedOutput) {
			slot := target.ReferenceSet.Slots["producer-output"]
			slot.Slot.MaxBytes = 1
			target.ReferenceSet.Slots["producer-output"] = slot
		}, want: "limit"},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			input, step, output := handoffFixture(t, codecNotes{Items: []string{"first"}})
			output = contracts.CloneSerializedOutput(output)
			if test.mutate != nil {
				target := &step.lanes[0].resolved.ExtractReferences
				test.mutate(target, &output)
			}
			_, _, err := buildStepReferenceSets(input, step, test.outputs(output))
			if err == nil || !strings.Contains(err.Error(), test.want) {
				t.Fatalf("buildStepReferenceSets() error = %v, want %q", err, test.want)
			}
		})
	}
}

func BenchmarkIndexGeneratedOutputs(b *testing.B) {
	outputs := make([]contracts.SerializedOutput, 0, 1024)
	for index := range 1024 {
		outputs = append(outputs, contracts.SerializedOutput{StepID: fmt.Sprintf("step-%d", index), LaneID: fmt.Sprintf("lane-%d", index)})
	}
	b.ResetTimer()
	for range b.N {
		_ = indexGeneratedOutputs(outputs)
	}
}

func TestGeneratedReferenceFingerprintChangesWithCanonicalContent(t *testing.T) {
	input, step, first := handoffFixture(t, codecNotes{Items: []string{"first"}})
	firstSets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{first})
	if err != nil {
		t.Fatalf("build first reference set: %v", err)
	}
	_, _, second := handoffFixture(t, codecNotes{Items: []string{"second"}})
	secondSets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{second})
	if err != nil {
		t.Fatalf("build second reference set: %v", err)
	}
	firstDeps := generatedReferenceDependencies(firstSets[keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)])
	secondDeps := generatedReferenceDependencies(secondSets[keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)])
	if reflect.DeepEqual(firstDeps, secondDeps) {
		t.Fatalf("generated dependencies = %#v, want content-sensitive fingerprint", firstDeps)
	}
}

func TestGeneratedReferenceFingerprintChangesWithCanonicalSize(t *testing.T) {
	input, step, producerOutput := handoffFixture(t, codecNotes{Items: []string{"first"}})
	sets, _, err := buildStepReferenceSets(input, step, []contracts.SerializedOutput{producerOutput})
	if err != nil {
		t.Fatalf("build reference set: %v", err)
	}
	key := keyForReferenceTarget(step.lanes[0].resolved.ExtractReferences)
	firstDeps := generatedReferenceDependencies(sets[key])
	changed := CloneReferenceSet(sets[key])
	item := changed.Slots["producer-output"].Items[0]
	item.SizeBytes++
	changed.Slots["producer-output"] = contracts.ResolvedReferenceSlot{
		Slot:  changed.Slots["producer-output"].Slot,
		Items: []contracts.ReferenceItem{item},
	}
	secondDeps := generatedReferenceDependencies(changed)
	if reflect.DeepEqual(firstDeps, secondDeps) {
		t.Fatalf("generated dependencies = %#v, want size-sensitive fingerprint", firstDeps)
	}
}

func TestRunnerHandsOffAcceptedNormalizedOutputBeforeConsumerLanes(t *testing.T) {
	input, _, _ := handoffFixture(t, codecNotes{Items: []string{"first"}})
	prepared := input.Prepared
	var received contracts.ReferenceSet
	prepared.Steps[0].lanes[0].typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
		return erasedTypedResult{Value: codecNotes{Items: []string{"first"}}}, nil
	}
	prepared.Steps[1].lanes[0].typed.extract = func(_ context.Context, _ any, request contracts.TypedExtractionRequest) (erasedTypedResult, error) {
		received = CloneReferenceSet(request.References)
		return erasedTypedResult{Value: codecScore{Value: 3}}, nil
	}

	output, err := New().Run(context.Background(), input)
	if err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if got, want := len(output.NormalizeOutputs), 2; got != want {
		t.Fatalf("normalized output count = %d, want %d", got, want)
	}
	if output.NormalizeOutputs[0].StepID != "step-1" || output.NormalizeOutputs[1].StepID != "step-2" {
		t.Fatalf("normalized output step IDs = %#v, want step-1 and step-2", output.NormalizeOutputs)
	}
	item := received.Slots["producer-output"].Items[0]
	if item.Origin.Type != "generated" || item.Producer.StepID != "step-1" || item.Producer.LaneID != "notes" {
		t.Fatalf("consumer reference = %#v, want generated producer reference", item)
	}
	if len(output.Manifest.References) == 0 || output.Manifest.References[len(output.Manifest.References)-1].OriginType != "generated" {
		t.Fatalf("manifest references = %#v, want generated provenance", output.Manifest.References)
	}
}

type handoffDependencyLoader struct {
	CheckpointLoader
	extract   [][]CheckpointFingerprint
	merge     [][]CheckpointFingerprint
	normalize [][]CheckpointFingerprint
}

func (l *handoffDependencyLoader) Enabled() bool { return true }

func (l *handoffDependencyLoader) Extract(_ string, _ string, dependencies []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) {
	l.extract = append(l.extract, append([]CheckpointFingerprint(nil), dependencies...))
	return ExtractCheckpoint{}, CheckpointDecision{Reason: "not found"}
}

func (l *handoffDependencyLoader) Merge(_ string, _ string, dependencies []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) {
	l.merge = append(l.merge, append([]CheckpointFingerprint(nil), dependencies...))
	return MergeCheckpoint{}, CheckpointDecision{Reason: "not found"}
}

func (l *handoffDependencyLoader) Normalize(_ string, _ string, dependencies []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) {
	l.normalize = append(l.normalize, append([]CheckpointFingerprint(nil), dependencies...))
	return NormalizeCheckpoint{}, CheckpointDecision{Reason: "not found"}
}

func runHandoffWithProducerValue(t *testing.T, value codecNotes) *handoffDependencyLoader {
	t.Helper()
	input, _, _ := handoffFixture(t, value)
	prepared := input.Prepared
	prepared.Steps[0].lanes[0].typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
		return erasedTypedResult{Value: value}, nil
	}
	prepared.Steps[0].lanes[0].typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
		return erasedTypedResult{Value: value}, nil
	}
	prepared.Steps[1].lanes[0].typed.extract = func(context.Context, any, contracts.TypedExtractionRequest) (erasedTypedResult, error) {
		return erasedTypedResult{Value: codecScore{Value: 3}}, nil
	}
	loader := &handoffDependencyLoader{CheckpointLoader: NoopCheckpointLoader()}
	if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoint: loader}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	return loader
}

func TestRunnerAddsGeneratedDependencyToEveryReceivingCheckpoint(t *testing.T) {
	first := runHandoffWithProducerValue(t, codecNotes{Items: []string{"first"}})
	second := runHandoffWithProducerValue(t, codecNotes{Items: []string{"second"}})
	if len(first.extract) != 2 || len(first.merge) != 2 || len(first.normalize) != 2 {
		t.Fatalf("checkpoint load calls = extract %d merge %d normalize %d, want two each", len(first.extract), len(first.merge), len(first.normalize))
	}
	for name, calls := range map[string][][]CheckpointFingerprint{"extract": first.extract, "merge": first.merge, "normalize": first.normalize} {
		if got := generatedFingerprintCount(calls[1]); got != 1 {
			t.Fatalf("%s consumer dependency count = %d, want one", name, got)
		}
	}
	if reflect.DeepEqual(first.extract[1], second.extract[1]) || reflect.DeepEqual(first.merge[1], second.merge[1]) || reflect.DeepEqual(first.normalize[1], second.normalize[1]) {
		t.Fatalf("consumer checkpoint dependencies did not change with producer content")
	}
}

func generatedFingerprintCount(dependencies []CheckpointFingerprint) int {
	count := 0
	for _, dependency := range dependencies {
		if strings.HasPrefix(dependency.Name, "generated-reference:") {
			count++
		}
	}
	return count
}
