package json

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

	"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
	"gitea.maximumdirect.net/eric/notarius/internal/core/source"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)

func TestModuleSpecAndRegister(t *testing.T) {
	want := pipeline.ModuleSpec{
		Key:            Key,
		Stage:          pipeline.StageOutput,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"normalized"},
		Provides:       []string{"encoded"},
	}
	if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
		t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
	}

	registry := pipeline.NewOutputEncoderRegistry()
	if err := Register(registry); err != nil {
		t.Fatalf("Register() error = %v, want nil", err)
	}
	spec, ok := registry.Spec(Key)
	if !ok {
		t.Fatalf("Spec(%q) ok = false, want true", Key)
	}
	if !reflect.DeepEqual(spec, want) {
		t.Fatalf("registered spec = %#v, want %#v", spec, want)
	}
	if err := registry.ValidateOptions(Key, map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
		t.Fatalf("ValidateOptions() error = %v, want unknown option error", err)
	}
	if err := registry.ValidateOptions(Key, map[string]any{"include_chunk_map": true}); err != nil {
		t.Fatalf("ValidateOptions() error = %v, want nil", err)
	}
}

func TestDecodeOptions(t *testing.T) {
	for _, test := range []struct {
		name    string
		options map[string]any
		want    Options
		wantErr string
	}{
		{name: "omitted", want: Options{}},
		{name: "disabled", options: map[string]any{"include_chunk_map": false}, want: Options{}},
		{name: "enabled", options: map[string]any{"include_chunk_map": true}, want: Options{IncludeChunkMap: true}},
		{name: "wrong type", options: map[string]any{"include_chunk_map": "true"}, wantErr: "must be a boolean"},
		{name: "unknown", options: map[string]any{"unknown": true}, wantErr: "unknown option"},
	} {
		t.Run(test.name, func(t *testing.T) {
			got, err := DecodeOptions(test.options)
			if test.wantErr != "" {
				if err == nil || !strings.Contains(err.Error(), test.wantErr) {
					t.Fatalf("DecodeOptions() error = %v, want %q", err, test.wantErr)
				}
				return
			}
			if err != nil {
				t.Fatalf("DecodeOptions() error = %v, want nil", err)
			}
			if !reflect.DeepEqual(got, test.want) {
				t.Fatalf("DecodeOptions() = %#v, want %#v", got, test.want)
			}
		})
	}
}

func TestDecodeEvidenceContextOptions(t *testing.T) {
	for _, test := range []struct {
		name    string
		options map[string]any
		want    pipeline.EvidenceContextPolicy
		wantErr string
	}{
		{name: "disabled", options: map[string]any{"evidence_context": map[string]any{"enabled": false}}},
		{name: "enabled default window", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs"}}}, want: pipeline.EvidenceContextPolicy{Enabled: true, WindowUnits: 3, LaneIDs: []string{"npcs"}}},
		{name: "explicit zero window and normalized lanes", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{" spells ", "npcs"}, "window_units": 0}}, want: pipeline.EvidenceContextPolicy{Enabled: true, WindowUnits: 0, LaneIDs: []string{"npcs", "spells"}}},
		{name: "duplicate lanes", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs", " npcs "}}}, wantErr: "duplicated"},
		{name: "unknown nested option", options: map[string]any{"evidence_context": map[string]any{"enabled": false, "extra": true}}, wantErr: "unknown option"},
		{name: "disabled nested fields", options: map[string]any{"evidence_context": map[string]any{"enabled": false, "lanes": []any{"npcs"}}}, wantErr: "not allowed"},
		{name: "invalid object", options: map[string]any{"evidence_context": true}, wantErr: "must be an object"},
		{name: "invalid lane type", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": "npcs"}}, wantErr: "must be an array"},
		{name: "invalid window type", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs"}, "window_units": "3"}}, wantErr: "must be an integer"},
	} {
		t.Run(test.name, func(t *testing.T) {
			got, err := DecodeOptions(test.options)
			if test.wantErr != "" {
				if err == nil || !strings.Contains(err.Error(), test.wantErr) {
					t.Fatalf("DecodeOptions() error = %v, want %q", err, test.wantErr)
				}
				return
			}
			if err != nil {
				t.Fatalf("DecodeOptions() error = %v, want nil", err)
			}
			if !reflect.DeepEqual(got.EvidenceContext, test.want) {
				t.Fatalf("EvidenceContext = %#v, want %#v", got.EvidenceContext, test.want)
			}
		})
	}
}

func TestProfileValidationRejectsUnknownEvidenceLaneAndCopiesInputs(t *testing.T) {
	registry := pipeline.NewOutputEncoderRegistry()
	if err := Register(registry); err != nil {
		t.Fatal(err)
	}
	options := map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs"}}}
	if err := registry.ValidateProfileOptions(Key, pipeline.OutputProfileOptionContext{LaneIDs: []string{"npcs", "spells"}}, options); err != nil {
		t.Fatalf("ValidateProfileOptions() error = %v, want nil", err)
	}
	if err := registry.ValidateProfileOptions(Key, pipeline.OutputProfileOptionContext{LaneIDs: []string{"spells"}}, options); err == nil || !strings.Contains(err.Error(), "not configured") {
		t.Fatalf("ValidateProfileOptions() error = %v, want unknown lane failure", err)
	}
}

func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) {
	req := contracts.OutputRequest{
		Manifest: artifacts.RunManifest{RunID: "run-1", PipelineID: "pipeline-1"},
		NormalizeOutputs: []contracts.SerializedOutput{
			normalizeOutput("spells", `{"spell_casts":[{"spell":"Cure Wounds"}]}`),
			normalizeOutput("notes/items", `{"items":[{"name":"Torch"}]}`),
		},
		Rejected: []contracts.RejectedOutput{
			{
				Stage:         "extract",
				LaneID:        "spells",
				ModuleKey:     "dnd/spells",
				ChunkID:       "chunk-1",
				ChunkIndex:    1,
				ReasonCode:    "invalid",
				Message:       "not accepted",
				AttemptCount:  1,
				ValidatorName: "validator",
			},
		},
		Diagnostics: warningDiagnostics("warning", "check source"),
	}

	result, err := New().Encode(context.Background(), req)
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}

	wantNames := []string{
		"diagnostics.json",
		"index.json",
		"lanes/notes_items.json",
		"lanes/spells.json",
		"manifest.json",
		"rejected.json",
		"warnings.json",
	}
	if got := outputFileNames(result.Files); !reflect.DeepEqual(got, wantNames) {
		t.Fatalf("file names = %#v, want %#v", got, wantNames)
	}

	for _, file := range result.Files {
		if !strings.HasSuffix(string(file.Bytes), "\n") {
			t.Fatalf("%s does not end with newline: %q", file.Name, string(file.Bytes))
		}
		if file.ContentType == contentTypeJSON && !stdjson.Valid(file.Bytes) {
			t.Fatalf("%s has invalid JSON: %s", file.Name, file.Bytes)
		}
	}

	spells := decodeObject(t, fileBytes(t, result.Files, "lanes/spells.json"))
	spellCasts := spells["spell_casts"].([]any)
	if spellCasts[0].(map[string]any)["spell"] != "Cure Wounds" {
		t.Fatalf("spells output = %#v, want raw normalized content", spells)
	}

	index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
	outputFiles := index["output_files"].([]any)
	if len(outputFiles) != 2 {
		t.Fatalf("len(index output_files) = %d, want 2", len(outputFiles))
	}
	firstIndex := outputFiles[0].(map[string]any)
	secondIndex := outputFiles[1].(map[string]any)
	if firstIndex["lane_id"] != "notes/items" || secondIndex["lane_id"] != "spells" {
		t.Fatalf("output_files = %#v, want sorted by lane id", outputFiles)
	}

	rejected := decodeObject(t, fileBytes(t, result.Files, "rejected.json"))
	if got := rejected["rejected"].([]any); len(got) != 1 {
		t.Fatalf("rejected = %#v, want one rejected output", got)
	}
}

func TestEncodeIncludesRejectedAndWarningsWhenEmpty(t *testing.T) {
	result, err := New().Encode(context.Background(), contracts.OutputRequest{
		Manifest: artifacts.RunManifest{RunID: "run-1"},
	})
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}

	rejected := decodeObject(t, fileBytes(t, result.Files, "rejected.json"))
	if got := rejected["rejected"].([]any); len(got) != 0 {
		t.Fatalf("rejected = %#v, want empty array", got)
	}
	warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json"))
	if got := warnings["groups"].([]any); len(got) != 0 {
		t.Fatalf("warnings = %#v, want empty array", got)
	}
}

func TestEncodePublishesVersionedDiagnosticPartitions(t *testing.T) {
	diagnostics := contracts.DiagnosticCollection{
		Groups: []contracts.DiagnosticGroup{
			{
				Disposition:        contracts.DiagnosticDispositionWarning,
				Category:           contracts.DiagnosticCategoryDegradation,
				ReasonCode:         "degraded",
				Origin:             contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageChunk},
				OccurrenceCount:    2,
				Samples:            []contracts.DiagnosticSample{{Scope: "chunk", Message: "degraded"}},
				OmittedSampleCount: 1,
			},
			{
				Disposition:        contracts.DiagnosticDispositionAdvisory,
				Category:           contracts.DiagnosticCategoryDataQuality,
				ReasonCode:         "unrelated",
				Origin:             contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageExtract},
				OccurrenceCount:    3,
				Samples:            []contracts.DiagnosticSample{{Scope: "items[0]", Message: "unrelated"}},
				OmittedSampleCount: 2,
			},
		},
		Truncated:                    true,
		UnrepresentedOccurrenceCount: 5,
	}
	result, err := New().Encode(context.Background(), contracts.OutputRequest{Diagnostics: diagnostics})
	if err != nil {
		t.Fatalf("Encode() error = %v", err)
	}
	warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json"))
	if warnings["schema_version"] != warningsSchemaVersion || warnings["group_count"] != float64(1) || warnings["occurrence_count"] != float64(2) || len(warnings["groups"].([]any)) != 1 {
		t.Fatalf("warnings file = %#v", warnings)
	}
	publishedDiagnostics := decodeObject(t, fileBytes(t, result.Files, "diagnostics.json"))
	if publishedDiagnostics["schema_version"] != diagnosticsSchemaVersion || publishedDiagnostics["group_count"] != float64(1) || publishedDiagnostics["occurrence_count"] != float64(8) || publishedDiagnostics["truncated"] != true || publishedDiagnostics["unrepresented_occurrence_count"] != float64(5) || len(publishedDiagnostics["groups"].([]any)) != 1 {
		t.Fatalf("diagnostics file = %#v", publishedDiagnostics)
	}
	index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
	if index["warnings_file"] != "warnings.json" || index["diagnostics_file"] != "diagnostics.json" {
		t.Fatalf("index = %#v", index)
	}
}

func TestEncodeChunkMapExportIsOptIn(t *testing.T) {
	artifact := acceptedChunkMapArtifact(t)
	request := contracts.OutputRequest{
		Manifest: artifacts.RunManifest{RunID: "run-1"},
		ChunkMap: &artifact,
	}
	for _, test := range []struct {
		name    string
		encoder *Encoder
	}{
		{name: "default", encoder: New()},
		{name: "explicitly disabled", encoder: NewWithOptions(Options{IncludeChunkMap: false})},
	} {
		t.Run(test.name, func(t *testing.T) {
			result, err := test.encoder.Encode(context.Background(), request)
			if err != nil {
				t.Fatalf("Encode() error = %v, want nil", err)
			}
			if got, want := outputFileNames(result.Files), []string{"diagnostics.json", "index.json", "manifest.json", "rejected.json", "warnings.json"}; !reflect.DeepEqual(got, want) {
				t.Fatalf("file names = %#v, want %#v", got, want)
			}
			index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
			if _, ok := index["chunk_map"]; ok {
				t.Fatalf("index = %#v, want no chunk map descriptor", index)
			}
		})
	}
}

func TestEncodeIncludesValidatedChunkMap(t *testing.T) {
	artifact := acceptedChunkMapArtifact(t)
	result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{
		Manifest: artifacts.RunManifest{RunID: "run-1"},
		NormalizeOutputs: []contracts.SerializedOutput{
			normalizeOutput("spells", `{"spell_casts":[]}`),
		},
		ChunkMap: &artifact,
	})
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}
	if got, want := outputFileNames(result.Files), []string{"chunk-map.json", "diagnostics.json", "index.json", "lanes/spells.json", "manifest.json", "rejected.json", "warnings.json"}; !reflect.DeepEqual(got, want) {
		t.Fatalf("file names = %#v, want %#v", got, want)
	}
	chunkMapFile := fileBytes(t, result.Files, chunkMapFileName)
	if !stdjson.Valid(chunkMapFile) || !strings.HasSuffix(string(chunkMapFile), "\n") || !strings.Contains(string(chunkMapFile), "\n  \"source_id\"") {
		t.Fatalf("chunk map file = %q, want pretty valid newline-terminated JSON", chunkMapFile)
	}
	if _, err := chunkmap.New().Decode(chunkMapFile); err != nil {
		t.Fatalf("Decode(chunk map file) error = %v, want nil", err)
	}
	index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
	if got, want := index["chunk_map"], map[string]any{
		"artifact_kind":  "source/chunk-map",
		"file":           chunkMapFileName,
		"media_type":     chunkmap.MediaType,
		"schema_id":      chunkmap.SchemaID,
		"schema_name":    chunkmap.SchemaName,
		"schema_version": chunkmap.SchemaVersion,
	}; !reflect.DeepEqual(got, want) {
		t.Fatalf("chunk map descriptor = %#v, want %#v", got, want)
	}
	for _, entry := range index["output_files"].([]any) {
		if entry.(map[string]any)["file"] == chunkMapFileName {
			t.Fatalf("output_files = %#v, want no chunk map", index["output_files"])
		}
	}
}

func TestEncodePreservesChunkMapAnnotationNumbers(t *testing.T) {
	artifact := acceptedChunkMapArtifactWithPlanAnnotation(t, stdjson.RawMessage(`{"decimal":1.0,"large":9007199254740993}`))
	result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{
		ChunkMap: &artifact,
	})
	if err != nil {
		t.Fatalf("Encode() error = %v", err)
	}
	value, err := chunkmap.New().Decode(fileBytes(t, result.Files, chunkMapFileName))
	if err != nil {
		t.Fatalf("Decode(emitted chunk map) error = %v", err)
	}
	if got, want := string(value.PlanAnnotations["test/numbers"]), `{"decimal":1.0,"large":9007199254740993}`; got != want {
		t.Fatalf("numeric annotation = %s, want %s", got, want)
	}
}

func TestEncodeOmitsChunkMapWithoutAcceptedArtifact(t *testing.T) {
	result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{
		Manifest: artifacts.RunManifest{RunID: "run-1"},
	})
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}
	if got := outputFileNames(result.Files); containsString(got, chunkMapFileName) {
		t.Fatalf("file names = %#v, want no chunk map", got)
	}
	index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
	if _, ok := index["chunk_map"]; ok {
		t.Fatalf("index = %#v, want no chunk map descriptor", index)
	}
}

func TestEncodeRejectsInvalidChunkMapArtifact(t *testing.T) {
	artifact := acceptedChunkMapArtifact(t)
	for _, test := range []struct {
		name   string
		mutate func(*contracts.SerializedArtifact)
	}{
		{name: "kind", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Kind = "other/chunk-map" }},
		{name: "schema", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.Version = "v2" }},
		{name: "media type", mutate: func(artifact *contracts.SerializedArtifact) { artifact.MediaType = "text/plain" }},
		{name: "content", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Content = []byte(`{}`) }},
	} {
		t.Run(test.name, func(t *testing.T) {
			candidate := contracts.CloneSerializedArtifact(artifact)
			test.mutate(&candidate)
			_, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), contracts.OutputRequest{ChunkMap: &candidate})
			if err == nil {
				t.Fatal("Encode() error = nil, want invalid chunk map error")
			}
		})
	}
}

func TestEncodeIncludesValidatedEvidenceContext(t *testing.T) {
	artifact := acceptedEvidenceContextArtifact(t)
	result, err := New().Encode(context.Background(), contracts.OutputRequest{EvidenceContext: &artifact})
	if err != nil {
		t.Fatalf("Encode() error = %v", err)
	}
	if got := string(fileBytes(t, result.Files, evidenceContextFileName)); !strings.HasSuffix(got, "\n") || !stdjson.Valid([]byte(got)) {
		t.Fatalf("evidence context file = %q, want pretty valid newline-terminated JSON", got)
	}
	value, err := evidencecontext.New().Decode(fileBytes(t, result.Files, evidenceContextFileName))
	if err != nil {
		t.Fatalf("Decode(evidence context file) error = %v", err)
	}
	if len(value) != 1 || value[0].ID != 7 || value[0].Text != "Source content retained only in the evidence artifact." {
		t.Fatalf("evidence context = %#v, want the published source-unit array", value)
	}
	index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
	if got, want := index["evidence_context"], map[string]any{
		"artifact_kind":  string(evidencecontext.ArtifactKind),
		"file":           evidenceContextFileName,
		"media_type":     evidencecontext.MediaType,
		"schema_id":      evidencecontext.SchemaID,
		"schema_name":    evidencecontext.SchemaName,
		"schema_version": evidencecontext.SchemaVersion,
	}; !reflect.DeepEqual(got, want) {
		t.Fatalf("evidence context descriptor = %#v, want %#v", got, want)
	}
	for _, entry := range index["output_files"].([]any) {
		if entry.(map[string]any)["file"] == evidenceContextFileName {
			t.Fatalf("output_files = %#v, want no evidence context lane entry", index["output_files"])
		}
	}
}

func TestEncodeRejectsInvalidEvidenceContextArtifactWithoutContentLeakage(t *testing.T) {
	artifact := acceptedEvidenceContextArtifact(t)
	for _, test := range []struct {
		name   string
		mutate func(*contracts.SerializedArtifact)
	}{
		{name: "kind", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Kind = "other/evidence" }},
		{name: "schema identity", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.Version = "v2" }},
		{name: "schema digest", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.JSONSchema[0] = '[' }},
		{name: "media type", mutate: func(artifact *contracts.SerializedArtifact) { artifact.MediaType = "text/plain" }},
		{name: "payload", mutate: func(artifact *contracts.SerializedArtifact) {
			artifact.Content = []byte(`{"source_id":"secret transcript text"}`)
		}},
	} {
		t.Run(test.name, func(t *testing.T) {
			candidate := contracts.CloneSerializedArtifact(artifact)
			test.mutate(&candidate)
			_, err := New().Encode(context.Background(), contracts.OutputRequest{EvidenceContext: &candidate})
			if err == nil || err.Error() != "json output encoder: evidence context artifact is invalid" {
				t.Fatalf("Encode() error = %v, want fixed evidence artifact error", err)
			}
			if strings.Contains(err.Error(), "secret transcript text") {
				t.Fatalf("Encode() leaked artifact content: %v", err)
			}
		})
	}
}

func TestEncodeOmitsEvidenceContextWhenArtifactIsAbsent(t *testing.T) {
	result, err := New().Encode(context.Background(), contracts.OutputRequest{Manifest: artifacts.RunManifest{RunID: "run-1"}})
	if err != nil {
		t.Fatalf("Encode() error = %v", err)
	}
	if got := outputFileNames(result.Files); containsString(got, evidenceContextFileName) {
		t.Fatalf("file names = %#v, want no evidence context", got)
	}
	index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
	if _, ok := index["evidence_context"]; ok {
		t.Fatalf("index = %#v, want no evidence context descriptor", index)
	}
}

func TestEncodeChunkMapDoesNotMutateRequest(t *testing.T) {
	artifact := acceptedChunkMapArtifact(t)
	artifact.Metadata = map[string]any{"owner": "caller"}
	request := contracts.OutputRequest{ChunkMap: &artifact}
	before := contracts.CloneSerializedArtifact(artifact)
	result, err := NewWithOptions(Options{IncludeChunkMap: true}).Encode(context.Background(), request)
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}
	if !reflect.DeepEqual(*request.ChunkMap, before) {
		t.Fatalf("chunk map artifact mutated:\nbefore: %#v\nafter: %#v", before, *request.ChunkMap)
	}
	request.ChunkMap.Content[0] = '['
	request.ChunkMap.Schema.JSONSchema[0] = '['
	request.ChunkMap.Metadata["owner"] = "changed"
	if _, err := chunkmap.New().Decode(fileBytes(t, result.Files, chunkMapFileName)); err != nil {
		t.Fatalf("chunk map output changed after request mutation: %v", err)
	}
}

func TestEncodePrettyPrintsJSON(t *testing.T) {
	result, err := New().Encode(context.Background(), contracts.OutputRequest{
		Manifest: artifacts.RunManifest{RunID: "run-1"},
	})
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}

	manifest := string(fileBytes(t, result.Files, "manifest.json"))
	if !strings.Contains(manifest, "\n  \"run_id\": \"run-1\"\n") {
		t.Fatalf("manifest JSON = %q, want two-space indentation", manifest)
	}
}

func TestEncodePublishesLLMProfileProvenance(t *testing.T) {
	result, err := New().Encode(context.Background(), contracts.OutputRequest{
		Manifest: artifacts.RunManifest{
			RunID: "run-1",
			LLMProfiles: []artifacts.LLMProfileManifest{
				{
					ID:              "endpoint-profile",
					Provider:        "promptkit",
					Model:           "endpoint-model",
					ReasoningEffort: "low",
				},
				{
					ID:              "profile",
					Provider:        "promptkit",
					Model:           "model",
					BackendID:       "openrouter",
					ReasoningEffort: "high",
				},
			},
		},
	})
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}

	manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json"))
	profiles := manifest["llm_profiles"].([]any)
	if len(profiles) != 2 {
		t.Fatalf("llm_profiles = %#v, want two entries", profiles)
	}
	endpointProfile := profiles[0].(map[string]any)
	if _, exists := endpointProfile["backend_id"]; exists || endpointProfile["reasoning_effort"] != "low" {
		t.Fatalf("endpoint-only LLM profile = %#v, want omitted backend and published reasoning", endpointProfile)
	}
	backendProfile := profiles[1].(map[string]any)
	if backendProfile["backend_id"] != "openrouter" || backendProfile["reasoning_effort"] != "high" {
		t.Fatalf("backend LLM profile = %#v, want published backend and reasoning fields", backendProfile)
	}
}

func TestEncodeIncludesManifestReferences(t *testing.T) {
	result, err := New().Encode(context.Background(), contracts.OutputRequest{
		Manifest: artifacts.RunManifest{
			RunID: "run-1",
			References: []artifacts.ReferenceProvenance{
				{
					Stage:         "extract",
					LaneID:        "events",
					SlotName:      "roster",
					OriginType:    "file",
					OriginURI:     "file:///tmp/roster.txt",
					Digest:        "sha256:reference",
					MediaType:     "text/plain; charset=utf-8",
					SizeBytes:     12,
					BindingSource: "config",
				},
			},
		},
	})
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}

	manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json"))
	references := manifest["references"].([]any)
	if len(references) != 1 {
		t.Fatalf("references = %#v, want one entry", references)
	}
	reference := references[0].(map[string]any)
	if reference["lane_id"] != "events" || reference["slot_name"] != "roster" || reference["digest"] != "sha256:reference" {
		t.Fatalf("reference manifest = %#v, want lane slot digest", reference)
	}
	if _, ok := reference["content"]; ok {
		t.Fatalf("reference manifest = %#v, want no content field", reference)
	}
}

func TestEncodeIncludesManifestRawOutputProvenance(t *testing.T) {
	result, err := New().Encode(context.Background(), contracts.OutputRequest{
		Manifest: artifacts.RunManifest{
			RunID: "run-1",
			NormalizedOutputs: []artifacts.NormalizedOutputManifest{
				{
					LaneID:    "spells",
					ModuleKey: "noop",
					SourceID:  "source-1",
					MediaType: contentTypeJSON,
					Schema: artifacts.OutputSchemaProvenance{
						ID:      "schema-id",
						Name:    "schema-name",
						Version: "v1",
					},
				},
			},
			RejectedOutputs: []artifacts.RejectedOutputManifest{
				{
					Stage:        "extract",
					LaneID:       "spells",
					ModuleKey:    "dnd/spells",
					ChunkID:      "chunk-0",
					ReasonCode:   "raw_output_rejected",
					AttemptCount: 2,
				},
			},
		},
	})
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}

	manifest := decodeObject(t, fileBytes(t, result.Files, "manifest.json"))
	normalized := manifest["normalized_outputs"].([]any)
	if len(normalized) != 1 {
		t.Fatalf("normalized_outputs = %#v, want one entry", normalized)
	}
	normalizedEntry := normalized[0].(map[string]any)
	if normalizedEntry["lane_id"] != "spells" || normalizedEntry["media_type"] != contentTypeJSON {
		t.Fatalf("normalized output manifest = %#v, want lane and media type", normalizedEntry)
	}
	rejected := manifest["rejected_outputs"].([]any)
	if len(rejected) != 1 {
		t.Fatalf("rejected_outputs = %#v, want one entry", rejected)
	}
	rejectedEntry := rejected[0].(map[string]any)
	if rejectedEntry["attempt_count"] != float64(2) || rejectedEntry["chunk_id"] != "chunk-0" {
		t.Fatalf("rejected output manifest = %#v, want attempt count and chunk", rejectedEntry)
	}
}

func TestEncodeRejectsLaneIDWithoutSafeFileName(t *testing.T) {
	_, err := New().Encode(context.Background(), contracts.OutputRequest{
		NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("///", `{"value":true}`)},
	})
	if err == nil {
		t.Fatal("Encode() error = nil, want unsafe lane id error")
	}
	if !strings.Contains(err.Error(), "json output encoder") || !strings.Contains(err.Error(), "safe file name") {
		t.Fatalf("Encode() error = %q, want safe file name context", err.Error())
	}
}

func TestEncodeSanitizesParentPathSequences(t *testing.T) {
	result, err := New().Encode(context.Background(), contracts.OutputRequest{
		NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("dnd..spell.", `{"value":true}`)},
	})
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}

	if got := outputFileNames(result.Files); !containsString(got, "lanes/dnd__spell.json") {
		t.Fatalf("file names = %#v, want sanitized output filename", got)
	}
}

func TestEncodeRejectsInvalidJSONAndUnsupportedMediaTypes(t *testing.T) {
	tests := []struct {
		name   string
		output contracts.SerializedOutput
		want   string
	}{
		{
			name:   "invalid JSON",
			output: normalizeOutput("spells", `{"spell_casts":[`),
			want:   "invalid JSON",
		},
		{
			name: "unsupported media type",
			output: func() contracts.SerializedOutput {
				output := normalizeOutput("spells", `{"spell_casts":[]}`)
				output.Artifact.MediaType = "text/plain"
				return output
			}(),
			want: "unsupported media type",
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			_, err := New().Encode(context.Background(), contracts.OutputRequest{
				NormalizeOutputs: []contracts.SerializedOutput{test.output},
			})
			if err == nil {
				t.Fatal("Encode() error = nil, want error")
			}
			if !strings.Contains(err.Error(), test.want) {
				t.Fatalf("Encode() error = %q, want %q", err.Error(), test.want)
			}
		})
	}
}

func TestEncodeRejectsSanitizedFilenameCollisions(t *testing.T) {
	_, err := New().Encode(context.Background(), contracts.OutputRequest{
		NormalizeOutputs: []contracts.SerializedOutput{
			normalizeOutput("a/b", `{"value":"slash"}`),
			normalizeOutput("a?b", `{"value":"question"}`),
		},
	})
	if err == nil {
		t.Fatal("Encode() error = nil, want duplicate file error")
	}
	if !strings.Contains(err.Error(), "duplicate output file") {
		t.Fatalf("Encode() error = %q, want duplicate file context", err.Error())
	}
}

func TestEncodeDoesNotMutateInputs(t *testing.T) {
	req := contracts.OutputRequest{
		Manifest: artifacts.RunManifest{RunID: "run-1"},
		NormalizeOutputs: []contracts.SerializedOutput{
			normalizeOutput("spells", `{"name":"original"}`),
		},
		Rejected: []contracts.RejectedOutput{
			{Stage: "extract", LaneID: "spells", Message: "not accepted"},
		},
		Diagnostics: warningDiagnostics("warning", "message"),
	}
	before := mustMarshal(t, req)

	result, err := New().Encode(context.Background(), req)
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}
	after := mustMarshal(t, req)
	if before != after {
		t.Fatalf("request mutated:\nbefore: %s\nafter:  %s", before, after)
	}

	req.NormalizeOutputs[0].Artifact.Content[0] = '['
	req.NormalizeOutputs[0].Artifact.Metadata["name"] = "changed"
	req.Rejected[0].Message = "changed"
	req.Diagnostics.Groups[0].Samples[0].Message = "changed"

	if !stdjson.Valid(fileBytes(t, result.Files, "lanes/spells.json")) {
		t.Fatal("output changed after request mutation")
	}
	warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json"))
	gotWarnings := warnings["groups"].([]any)
	if gotWarnings[0].(map[string]any)["samples"].([]any)[0].(map[string]any)["message"] != "message" {
		t.Fatalf("warnings output changed after request mutation: %#v", gotWarnings)
	}
}

func TestOutputFilesDoNotContainWarnings(t *testing.T) {
	result, err := New().Encode(context.Background(), contracts.OutputRequest{
		NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("spells", `{"spell":"Shield"}`)},
		Diagnostics:      warningDiagnostics("pipeline-warning", "warning"),
	})
	if err != nil {
		t.Fatalf("Encode() error = %v, want nil", err)
	}

	outputFile := decodeObject(t, fileBytes(t, result.Files, "lanes/spells.json"))
	if _, ok := outputFile["warnings"]; ok {
		t.Fatalf("output file contains warnings: %#v", outputFile)
	}
}

func warningDiagnostics(reasonCode, message string) contracts.DiagnosticCollection {
	return contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{{
		Disposition:     contracts.DiagnosticDispositionWarning,
		Category:        contracts.DiagnosticCategoryDegradation,
		ReasonCode:      reasonCode,
		Origin:          contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageChunk},
		OccurrenceCount: 1,
		Samples:         []contracts.DiagnosticSample{{Scope: "test", Message: message}},
	}}}
}

func normalizeOutput(laneID string, content string) contracts.SerializedOutput {
	return contracts.SerializedOutput{
		LaneID:        laneID,
		NormalizerKey: "noop",
		SourceID:      "source-1",
		Artifact: contracts.SerializedArtifact{Schema: contracts.ArtifactSchema{
			ID:      "schema-id",
			Name:    "schema-name",
			Version: "v1",
		},
			Content:   []byte(content),
			MediaType: contentTypeJSON,
			Metadata:  map[string]any{"name": laneID},
		},
	}
}

func acceptedChunkMapArtifact(t *testing.T) contracts.SerializedArtifact {
	return acceptedChunkMapArtifactWithPlanAnnotation(t, nil)
}

func acceptedChunkMapArtifactWithPlanAnnotation(t *testing.T, annotation stdjson.RawMessage) contracts.SerializedArtifact {
	t.Helper()
	document := &source.SourceDocument{
		ID:     "source-1",
		Kind:   "text",
		Format: "text/plain",
		Units: []source.SourceUnit{{
			ID:   1,
			Kind: "text",
			Text: "Accepted source content.",
			Ref:  source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1},
		}},
	}
	digest, err := source.DigestDocument(document)
	if err != nil {
		t.Fatal(err)
	}
	document.Digest = digest
	plan := source.ChunkPlan{SourceDigest: digest, Ranges: []source.ChunkRange{{StartUnitID: 1, EndUnitID: 1}}}
	if annotation != nil {
		plan.Annotations = source.ChunkAnnotations{"test/numbers": append(stdjson.RawMessage(nil), annotation...)}
	}
	chunks, err := source.MaterializeChunkPlan(document, plan)
	if err != nil {
		t.Fatal(err)
	}
	artifact, err := chunkmap.Serialize(chunkmap.BuildRequest{
		Source: document, Plan: plan, Chunks: chunks, RequestedChunker: "generic/units",
		Producer: chunkmap.Producer{InputModule: "test/input", ChunkModule: "generic/units"},
	})
	if err != nil {
		t.Fatal(err)
	}
	return artifact
}

func acceptedEvidenceContextArtifact(t *testing.T) contracts.SerializedArtifact {
	t.Helper()
	document := &source.SourceDocument{
		ID:     "source-1",
		Kind:   "text",
		Format: "text/plain",
		Units: []source.SourceUnit{{
			ID:   7,
			Kind: "text",
			Text: "Source content retained only in the evidence artifact.",
			Ref:  source.SourceRef{SourceID: "source-1", StartUnitID: 7, EndUnitID: 7},
		}},
	}
	digest, err := source.DigestDocument(document)
	if err != nil {
		t.Fatal(err)
	}
	document.Digest = digest
	artifact, err := evidencecontext.Serialize(evidencecontext.BuildRequest{
		Source: document, WindowUnits: 3,
		SourceRefs: []source.SourceRef{{SourceID: "source-1", StartUnitID: 7, EndUnitID: 7}},
	})
	if err != nil {
		t.Fatal(err)
	}
	return artifact
}

func outputFileNames(files []contracts.OutputFile) []string {
	names := make([]string, 0, len(files))
	for _, file := range files {
		names = append(names, file.Name)
	}
	return names
}

func containsString(values []string, want string) bool {
	for _, value := range values {
		if value == want {
			return true
		}
	}
	return false
}

func fileBytes(t *testing.T, files []contracts.OutputFile, name string) []byte {
	t.Helper()
	for _, file := range files {
		if file.Name == name {
			return file.Bytes
		}
	}
	t.Fatalf("file %q not found in %#v", name, outputFileNames(files))
	return nil
}

func decodeObject(t *testing.T, data []byte) map[string]any {
	t.Helper()
	var got map[string]any
	if err := stdjson.Unmarshal(data, &got); err != nil {
		t.Fatalf("Unmarshal() error = %v, want nil\n%s", err, data)
	}
	return got
}

func mustMarshal(t *testing.T, value any) string {
	t.Helper()
	data, err := stdjson.Marshal(value)
	if err != nil {
		t.Fatalf("Marshal() error = %v, want nil", err)
	}
	return string(data)
}
