package config

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"

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

func TestDefaultReturnsDocumentedValuesAndIndependentMaps(t *testing.T) {
	first := Default()
	if first.Concurrency.TotalLLM != 16 || first.Concurrency.StageWorkers["extract"] != 16 {
		t.Fatalf("concurrency defaults = %#v", first.Concurrency)
	}
	if first.Output.Directory != "./notarius-output" || first.Debug.Directory != "./notarius-debug" {
		t.Fatalf("output/debug defaults = %#v, %#v", first.Output, first.Debug)
	}
	if first.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto || first.Cache.ChunkPlans.Directory != "" || first.Cache.Checkpoints.Enabled || first.Cache.Checkpoints.Directory != "" {
		t.Fatalf("cache defaults = %#v", first.Cache)
	}
	if len(first.Pipelines) != 0 {
		t.Fatalf("pipeline defaults = %#v", first.Pipelines)
	}

	first.Concurrency.StageWorkers["extract"] = 99
	first.Concurrency.StageWorkers["other"] = 100
	first.Pipelines["changed"] = pipeline.PipelineProfile{}
	second := Default()
	if second.Concurrency.StageWorkers["extract"] != 16 || len(second.Concurrency.StageWorkers) != 1 || len(second.Pipelines) != 0 {
		t.Fatalf("Default() returned state shared with an earlier result: %#v", second)
	}
}

func TestFileConfigMinimalVersion4AppliesOverDefaults(t *testing.T) {
	file := parseFileConfig(t, "version: 4\n")
	cfg := Default()
	if err := cfg.ApplyFileConfig(file); err != nil {
		t.Fatal(err)
	}
	if cfg.Output.Directory != "./notarius-output" || cfg.Debug.Directory != "./notarius-debug" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto {
		t.Fatalf("minimal file changed unrelated defaults: %#v", cfg)
	}
	if cfg.Concurrency.TotalLLM != 16 || cfg.Concurrency.StageWorkers["extract"] != 16 || len(cfg.Pipelines) != 0 {
		t.Fatalf("minimal file did not retain defaults: %#v", cfg)
	}
}

func TestParseFileConfigYAMLRejectsAdditionalDocuments(t *testing.T) {
	tests := []struct {
		name    string
		source  string
		wantErr bool
	}{
		{name: "trailing whitespace", source: "version: 4\n\n  \t", wantErr: false},
		{name: "trailing comment", source: "version: 4\n# trailing comment\n", wantErr: false},
		{name: "second valid document", source: "version: 4\n---\nversion: 4\n", wantErr: true},
		{name: "second empty document", source: "version: 4\n---\n", wantErr: true},
		{name: "second malformed document", source: "version: 4\n---\nversion: [\n", wantErr: true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			_, err := ParseFileConfigYAML([]byte(tt.source))
			if (err != nil) != tt.wantErr {
				t.Fatalf("ParseFileConfigYAML() error = %v, want error=%t", err, tt.wantErr)
			}
		})
	}
}

func TestFilePipelineLLMProfileIsPresenceAwareAndDetached(t *testing.T) {
	const pipelineYAML = `version: 4
pipelines:
  main:
    %s
    input: input
    artifacts:
      lane:
        extract: extract
`

	t.Run("omitted", func(t *testing.T) {
		file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, ""))
		if file.Pipelines["main"].LLMProfile != nil || file.Pipelines["main"].llmProfileSet {
			t.Fatalf("parsed pipeline profile = %#v, want omitted llm profile", file.Pipelines["main"])
		}
		cfg := Default()
		if err := cfg.ApplyFileConfig(file); err != nil {
			t.Fatal(err)
		}
		if got := cfg.Pipelines["main"].LLMProfile; got != "" {
			t.Fatalf("pipeline llm profile = %q, want empty", got)
		}
	})

	t.Run("trimmed and detached", func(t *testing.T) {
		file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, "llm_profile: ' configured-profile '"))
		cfg := Default()
		if err := cfg.ApplyFileConfig(file); err != nil {
			t.Fatal(err)
		}
		if got := cfg.Pipelines["main"].LLMProfile; got != "configured-profile" {
			t.Fatalf("pipeline llm profile = %q, want trimmed value", got)
		}
		*file.Pipelines["main"].LLMProfile = "changed-profile"
		if got := cfg.Pipelines["main"].LLMProfile; got != "configured-profile" {
			t.Fatalf("effective config aliases parsed file: %q", got)
		}
		if got := cloneConfig(cfg).Pipelines["main"].LLMProfile; got != "configured-profile" {
			t.Fatalf("cloned pipeline llm profile = %q", got)
		}
		data, err := json.Marshal(cfg)
		if err != nil {
			t.Fatal(err)
		}
		var roundTripped Config
		if err := json.Unmarshal(data, &roundTripped); err != nil {
			t.Fatal(err)
		}
		if got := roundTripped.Pipelines["main"].LLMProfile; got != "configured-profile" {
			t.Fatalf("round-tripped pipeline llm profile = %q", got)
		}
	})

	for _, value := range []string{"''", "'   '", "null"} {
		t.Run("explicit empty "+value, func(t *testing.T) {
			file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, "llm_profile: "+value))
			cfg := Default()
			err := cfg.ApplyFileConfig(file)
			if err == nil || !strings.Contains(err.Error(), `pipeline "main" llm_profile must not be empty`) {
				t.Fatalf("ApplyFileConfig() error = %v, want explicit-empty rejection", err)
			}
		})
	}
}

func TestStructuredOutputRepairAttemptsFileConfigurationPreservesPresenceAndOwnership(t *testing.T) {
	const pipelineYAML = `version: 4
pipelines:
  main:
    %s
    input: input
    artifacts:
      lane:
        extract:
          module: extract
          structured_output_repair_attempts: 2
          validators:
            - module: validator
              structured_output_repair_attempts: 3
`

	t.Run("omitted pipeline value remains absent", func(t *testing.T) {
		file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, ""))
		if got := file.Pipelines["main"].StructuredOutputRepairAttempts; got != nil {
			t.Fatalf("file pipeline repair attempts = %v, want nil", *got)
		}
		cfg := Default()
		if err := cfg.ApplyFileConfig(file); err != nil {
			t.Fatal(err)
		}
		if got := cfg.Pipelines["main"].StructuredOutputRepairAttempts; got != nil {
			t.Fatalf("pipeline repair attempts = %v, want nil", *got)
		}
		if got := cfg.Pipelines["main"].Input.StructuredOutputRepairAttempts; got != nil {
			t.Fatalf("scalar input binding repair attempts = %v, want nil", *got)
		}
	})

	t.Run("zero is explicit and survives configuration boundaries", func(t *testing.T) {
		file := parseFileConfig(t, fmt.Sprintf(pipelineYAML, "structured_output_repair_attempts: 0"))
		cfg := Default()
		if err := cfg.ApplyFileConfig(file); err != nil {
			t.Fatal(err)
		}
		profile := cfg.Pipelines["main"]
		if profile.StructuredOutputRepairAttempts == nil || *profile.StructuredOutputRepairAttempts != 0 {
			t.Fatalf("pipeline repair attempts = %v, want explicit zero", profile.StructuredOutputRepairAttempts)
		}
		lane := profile.Artifacts["lane"]
		if lane.Extract.StructuredOutputRepairAttempts == nil || *lane.Extract.StructuredOutputRepairAttempts != 2 {
			t.Fatalf("extract repair attempts = %v, want 2", lane.Extract.StructuredOutputRepairAttempts)
		}
		if lane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts == nil || *lane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts != 3 {
			t.Fatalf("validator repair attempts = %v, want 3", lane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts)
		}

		*file.Pipelines["main"].StructuredOutputRepairAttempts = 1
		if got := *cfg.Pipelines["main"].StructuredOutputRepairAttempts; got != 0 {
			t.Fatalf("applied config aliased file configuration: got %d, want 0", got)
		}
		fileProfile := file.Pipelines["main"]
		fileLane := fileProfile.Artifacts["lane"]
		*fileLane.Extract.StructuredOutputRepairAttempts = 1
		*fileLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts = 1
		fileProfile.Artifacts["lane"] = fileLane
		file.Pipelines["main"] = fileProfile
		configuredLane := cfg.Pipelines["main"].Artifacts["lane"]
		if got := *configuredLane.Extract.StructuredOutputRepairAttempts; got != 2 {
			t.Fatalf("configured extract aliased file configuration: got %d, want 2", got)
		}
		if got := *configuredLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts; got != 3 {
			t.Fatalf("configured validator aliased file configuration: got %d, want 3", got)
		}
		cloned := cloneConfig(cfg)
		*cloned.Pipelines["main"].StructuredOutputRepairAttempts = 1
		if got := *cfg.Pipelines["main"].StructuredOutputRepairAttempts; got != 0 {
			t.Fatalf("cloned config aliased source configuration: got %d, want 0", got)
		}
		redacted := cfg.Redacted()
		*redacted.Pipelines["main"].StructuredOutputRepairAttempts = 1
		if got := *cfg.Pipelines["main"].StructuredOutputRepairAttempts; got != 0 {
			t.Fatalf("redacted config aliased source configuration: got %d, want 0", got)
		}
		summary := cfg.RedactedSummaryPayload().(Config)
		if summary.Pipelines["main"].StructuredOutputRepairAttempts == nil || *summary.Pipelines["main"].StructuredOutputRepairAttempts != 0 {
			t.Fatalf("redacted summary pipeline repair attempts = %v, want explicit zero", summary.Pipelines["main"].StructuredOutputRepairAttempts)
		}

		data, err := json.Marshal(cfg)
		if err != nil {
			t.Fatal(err)
		}
		var roundTripped Config
		if err := json.Unmarshal(data, &roundTripped); err != nil {
			t.Fatal(err)
		}
		roundTrippedProfile := roundTripped.Pipelines["main"]
		if roundTrippedProfile.StructuredOutputRepairAttempts == nil || *roundTrippedProfile.StructuredOutputRepairAttempts != 0 {
			t.Fatalf("round-tripped pipeline repair attempts = %v, want explicit zero", roundTrippedProfile.StructuredOutputRepairAttempts)
		}
		roundTrippedLane := roundTrippedProfile.Artifacts["lane"]
		if roundTrippedLane.Extract.StructuredOutputRepairAttempts == nil || *roundTrippedLane.Extract.StructuredOutputRepairAttempts != 2 {
			t.Fatalf("round-tripped extract repair attempts = %v, want 2", roundTrippedLane.Extract.StructuredOutputRepairAttempts)
		}
		if roundTrippedLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts == nil || *roundTrippedLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts != 3 {
			t.Fatalf("round-tripped validator repair attempts = %v, want 3", roundTrippedLane.Extract.Validators.Validators[0].StructuredOutputRepairAttempts)
		}
	})
}

func TestStructuredOutputRepairAttemptsFileConfigurationRejectsInvalidValues(t *testing.T) {
	const pipelineYAML = `version: 4
pipelines:
  main:
    input: input
    artifacts:
      lane:
        extract: extract
    %s
`
	const bindingYAML = `version: 4
pipelines:
  main:
    input:
      module: input
      %s
    artifacts:
      lane:
        extract: extract
`

	for _, tt := range []struct {
		name   string
		source string
		want   string
	}{
		{name: "null pipeline value", source: "structured_output_repair_attempts: null", want: "pipeline profile structured_output_repair_attempts must not be null"},
		{name: "fractional pipeline value", source: "structured_output_repair_attempts: 1.5", want: "pipeline profile structured_output_repair_attempts must be an integer"},
		{name: "quoted pipeline value", source: "structured_output_repair_attempts: '1'", want: "pipeline profile structured_output_repair_attempts must be an integer"},
		{name: "out of range pipeline value", source: "structured_output_repair_attempts: 4", want: "pipeline profile structured_output_repair_attempts must be between zero and three"},
	} {
		t.Run(tt.name, func(t *testing.T) {
			_, err := ParseFileConfigYAML([]byte(fmt.Sprintf(pipelineYAML, tt.source)))
			if err == nil || !strings.Contains(err.Error(), tt.want) {
				t.Fatalf("ParseFileConfigYAML() error = %v, want %q", err, tt.want)
			}
		})
	}

	for _, tt := range []struct {
		name   string
		source string
		want   string
	}{
		{name: "null binding value", source: "structured_output_repair_attempts: null", want: "module binding structured_output_repair_attempts must not be null"},
		{name: "noninteger binding value", source: "structured_output_repair_attempts: true", want: "module binding structured_output_repair_attempts must be an integer"},
		{name: "out of range binding value", source: "structured_output_repair_attempts: -1", want: "module binding structured_output_repair_attempts must be between zero and three"},
	} {
		t.Run(tt.name, func(t *testing.T) {
			_, err := ParseFileConfigYAML([]byte(fmt.Sprintf(bindingYAML, tt.source)))
			if err == nil || !strings.Contains(err.Error(), tt.want) {
				t.Fatalf("ParseFileConfigYAML() error = %v, want %q", err, tt.want)
			}
		})
	}
}

func TestFileModuleBindingRejectsExplicitEmptyLLMProfile(t *testing.T) {
	const configYAML = `version: 4
pipelines:
  main:
    input:
      module: input
      llm_profile: %s
    artifacts:
      lane:
        extract: extract
`
	for _, value := range []string{"''", "'   '", "null"} {
		t.Run(value, func(t *testing.T) {
			_, err := ParseFileConfigYAML([]byte(fmt.Sprintf(configYAML, value)))
			if err == nil || !strings.Contains(err.Error(), "llm_profile must not be empty when set") {
				t.Fatalf("ParseFileConfigYAML() error = %v, want explicit-empty binding profile rejection", err)
			}
		})
	}
}

func TestFilePromptKitProfileSourcesSurviveConfigBoundaries(t *testing.T) {
	tests := []struct {
		name string
		yaml string
		want PromptKitConfig
	}{
		{
			name: "profile directory",
			yaml: "version: 4\npromptkit:\n  profile_dir: ' ./profiles '\n",
			want: PromptKitConfig{ProfileDir: "./profiles"},
		},
		{
			name: "profile file",
			yaml: "version: 4\npromptkit:\n  profile_file: ' ./profiles.yml '\n",
			want: PromptKitConfig{ProfileFile: "./profiles.yml"},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			cfg := applyFileConfig(t, tt.yaml)
			if cfg.PromptKit != tt.want {
				t.Fatalf("PromptKit config = %#v, want %#v", cfg.PromptKit, tt.want)
			}
			if got := cloneConfig(cfg).PromptKit; got != tt.want {
				t.Fatalf("cloned PromptKit config = %#v, want %#v", got, tt.want)
			}
			if got := cfg.Redacted().PromptKit; got != tt.want {
				t.Fatalf("redacted PromptKit config = %#v, want %#v", got, tt.want)
			}

			data, err := json.Marshal(cfg)
			if err != nil {
				t.Fatalf("json.Marshal() error = %v", err)
			}
			var payload map[string]json.RawMessage
			if err := json.Unmarshal(data, &payload); err != nil {
				t.Fatalf("json.Unmarshal() error = %v", err)
			}
			if _, ok := payload["promptkit"]; !ok {
				t.Fatalf("runtime JSON keys = %v, want promptkit", payload)
			}
			if _, ok := payload["scriptorium"]; ok {
				t.Fatalf("runtime JSON keys = %v, must not contain removed section", payload)
			}
		})
	}
}

func TestFilePromptKitLocalBackendSurvivesConfigBoundaries(t *testing.T) {
	tests := []struct {
		name            string
		concurrencyYAML string
		wantConcurrency int
	}{
		{name: "omitted concurrency defaults to zero"},
		{name: "positive concurrency is preserved", concurrencyYAML: "    concurrency_limit: 2\n", wantConcurrency: 2},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			file := parseFileConfig(t, "version: 4\npromptkit:\n  local_backend:\n    endpoint: ' http://localhost:8000/v1 '\n"+tt.concurrencyYAML)
			cfg := Default()
			if err := cfg.ApplyFileConfig(file); err != nil {
				t.Fatalf("ApplyFileConfig() error = %v", err)
			}
			want := PromptKitLocalBackendConfig{
				Endpoint:         "http://localhost:8000/v1",
				ConcurrencyLimit: tt.wantConcurrency,
			}
			if cfg.PromptKit.LocalBackend == nil || *cfg.PromptKit.LocalBackend != want {
				t.Fatalf("local backend config = %#v, want %#v", cfg.PromptKit.LocalBackend, want)
			}

			*file.PromptKit.LocalBackend.Endpoint = "http://changed.example/v1"
			if file.PromptKit.LocalBackend.ConcurrencyLimit != nil {
				*file.PromptKit.LocalBackend.ConcurrencyLimit = 99
			}
			if *cfg.PromptKit.LocalBackend != want {
				t.Fatalf("effective config aliases parsed file model: %#v", cfg.PromptKit.LocalBackend)
			}

			cloned := cloneConfig(cfg)
			if cloned.PromptKit.LocalBackend == cfg.PromptKit.LocalBackend || *cloned.PromptKit.LocalBackend != want {
				t.Fatalf("cloned local backend = %#v, want detached %#v", cloned.PromptKit.LocalBackend, want)
			}
			cloned.PromptKit.LocalBackend.Endpoint = "http://clone.example/v1"
			if *cfg.PromptKit.LocalBackend != want {
				t.Fatalf("mutating clone changed source config: %#v", cfg.PromptKit.LocalBackend)
			}

			redacted := cfg.Redacted()
			if redacted.PromptKit.LocalBackend == cfg.PromptKit.LocalBackend || *redacted.PromptKit.LocalBackend != want {
				t.Fatalf("redacted local backend = %#v, want detached %#v", redacted.PromptKit.LocalBackend, want)
			}

			data, err := json.Marshal(cfg)
			if err != nil {
				t.Fatalf("json.Marshal() error = %v", err)
			}
			var payload struct {
				PromptKit map[string]json.RawMessage `json:"promptkit"`
			}
			if err := json.Unmarshal(data, &payload); err != nil {
				t.Fatalf("json.Unmarshal() error = %v", err)
			}
			localJSON, ok := payload.PromptKit["local_backend"]
			if !ok {
				t.Fatalf("runtime PromptKit JSON keys = %v, want local_backend", payload.PromptKit)
			}
			var localPayload map[string]json.RawMessage
			if err := json.Unmarshal(localJSON, &localPayload); err != nil {
				t.Fatalf("unmarshal local_backend JSON: %v", err)
			}
			if _, ok := localPayload["endpoint"]; !ok {
				t.Fatalf("runtime local_backend JSON keys = %v, want endpoint", localPayload)
			}
			if _, ok := localPayload["concurrency_limit"]; !ok {
				t.Fatalf("runtime local_backend JSON keys = %v, want concurrency_limit", localPayload)
			}
		})
	}
}

func TestFilePromptKitLocalBackendRequiresEndpoint(t *testing.T) {
	for _, tt := range []struct {
		name string
		yaml string
	}{
		{name: "missing", yaml: "version: 4\npromptkit:\n  local_backend: {}\n"},
		{name: "empty", yaml: "version: 4\npromptkit:\n  local_backend:\n    endpoint: ''\n"},
		{name: "blank", yaml: "version: 4\npromptkit:\n  local_backend:\n    endpoint: '   '\n"},
	} {
		t.Run(tt.name, func(t *testing.T) {
			file := parseFileConfig(t, tt.yaml)
			cfg := Default()
			err := cfg.ApplyFileConfig(file)
			if err == nil || !strings.Contains(err.Error(), "promptkit.local_backend.endpoint") {
				t.Fatalf("ApplyFileConfig() error = %v, want endpoint field context", err)
			}
		})
	}
}

func TestFilePromptKitExplicitEmptyProfileSourcesAreRejected(t *testing.T) {
	for _, field := range []string{"profile_dir", "profile_file"} {
		t.Run(field, func(t *testing.T) {
			file := parseFileConfig(t, "version: 4\npromptkit:\n  "+field+": ''\n")
			cfg := Default()
			err := cfg.ApplyFileConfig(file)
			if err == nil || !strings.Contains(err.Error(), "promptkit."+field+" must not be empty") {
				t.Fatalf("ApplyFileConfig() error = %v, want explicit-empty rejection", err)
			}
		})
	}
}

func TestFilePromptKitProfileSourcesRemainMutuallyExclusive(t *testing.T) {
	cfg := applyFileConfig(t, `version: 4
promptkit:
  profile_dir: ./profiles
  profile_file: ./profiles.yml
`)
	if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "promptkit profile_dir and profile_file are mutually exclusive") {
		t.Fatalf("Validate() error = %v, want mutually exclusive profile sources", err)
	}
}

func TestFileConfigMissingVersionIsReportedBeforeFieldDecoding(t *testing.T) {
	_, err := ParseFileConfigYAML([]byte("workspace:\n  directory: /tmp/old\n"))
	if err == nil || !strings.Contains(err.Error(), "config version is required") {
		t.Fatalf("missing version error = %v", err)
	}
}

func TestFileConfigVersion3ReportsPromptKitMigration(t *testing.T) {
	_, err := ParseFileConfigYAML([]byte("version: 3\nscriptorium:\n  profile_dir: ./profiles\n"))
	if err == nil ||
		!strings.Contains(err.Error(), `change "version: 3" to "version: 4"`) ||
		!strings.Contains(err.Error(), `rename "scriptorium:" to "promptkit:"`) {
		t.Fatalf("version 3 error = %v, want actionable version and section migration", err)
	}
}

func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) {
	tests := []struct {
		name string
		yaml string
		want string
	}{
		{
			name: "removed diagnostics",
			yaml: "version: 4\ndiagnostics: {}\n",
			want: "field diagnostics not found",
		},
		{
			name: "removed llm profiles",
			yaml: "version: 4\nllm_profiles: {}\n",
			want: "field llm_profiles not found",
		},
		{
			name: "removed scriptorium section",
			yaml: "version: 4\nscriptorium: {}\n",
			want: "field scriptorium not found",
		},
		{
			name: "version 2 migration",
			yaml: "version: 2\nworkspace:\n  directory: /tmp/old\n",
			want: "version 2-to-3 migration",
		},
		{
			name: "pipeline field",
			yaml: "version: 4\npipelines:\n  main:\n    unknown: true\n",
			want: "field unknown not found",
		},
		{
			name: "lane field",
			yaml: "version: 4\npipelines:\n  main:\n    artifacts:\n      spells:\n        unknown: true\n",
			want: "field unknown not found",
		},
		{
			name: "module binding field",
			yaml: "version: 4\npipelines:\n  main:\n    input:\n      module: seriatim\n      unknown: true\n",
			want: "field unknown not found in module binding",
		},
		{
			name: "checkpoint field",
			yaml: "version: 4\ncache:\n  checkpoints:\n    unknown: true\n",
			want: "field unknown not found",
		},
		{
			name: "checkpoint enabled type",
			yaml: "version: 4\ncache:\n  checkpoints:\n    enabled: definitely\n",
			want: "cannot unmarshal",
		},
		{
			name: "local backend field",
			yaml: "version: 4\npromptkit:\n  local_backend:\n    endpoint: http://localhost:8000/v1\n    unknown: true\n",
			want: "field unknown not found",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			_, err := ParseFileConfigYAML([]byte(tt.yaml))
			if err == nil || !strings.Contains(err.Error(), tt.want) {
				t.Fatalf("error = %v, want context %q", err, tt.want)
			}
		})
	}
}

func TestFileConfigModuleBindingsPreserveFormsAndValidatorPresence(t *testing.T) {
	cfg := applyFileConfig(t, `version: 4
pipelines:
  main:
    input: seriatim
    chunk:
      module: generic
      llm_profile: chunk-profile
      retries: 2
      options:
        max_units: 25
      references:
        glossary: ./glossary.md
      validators: []
    artifacts:
      spells:
        extract:
          module: dnd/spells
          options:
            nested:
              enabled: true
        merge: appendorder
        normalize: noop
`)
	profile := cfg.Pipelines["main"]
	if profile.Input.Module != "seriatim" || profile.Input.Validators.Set {
		t.Fatalf("shorthand binding = %#v", profile.Input)
	}
	if profile.Chunk.Module != "generic" || profile.Chunk.LLMProfile != "chunk-profile" || profile.Chunk.Retries != 2 ||
		!reflect.DeepEqual(profile.Chunk.Options, map[string]any{"max_units": 25}) ||
		!reflect.DeepEqual(profile.Chunk.References, pipeline.ExternalReferenceMap(map[string]string{"glossary": "./glossary.md"})) {
		t.Fatalf("object binding = %#v", profile.Chunk)
	}
	if !profile.Chunk.Validators.Set || len(profile.Chunk.Validators.Validators) != 0 {
		t.Fatalf("explicit empty validators = %#v", profile.Chunk.Validators)
	}
	if profile.Artifacts["spells"].Extract.Module != "dnd/spells" ||
		!reflect.DeepEqual(profile.Artifacts["spells"].Extract.Options, map[string]any{
			"nested": map[string]any{"enabled": true},
		}) {
		t.Fatalf("extract binding = %#v", profile.Artifacts["spells"].Extract)
	}
	if profile.Artifacts["spells"].Merge.Module != "appendorder" || profile.Artifacts["spells"].Normalize.Module != "noop" {
		t.Fatalf("stage shorthand bindings = %#v", profile.Artifacts["spells"])
	}
}

func TestFileConfigReferencePrecedenceIsRetained(t *testing.T) {
	cfg := applyFileConfig(t, `version: 4
pipelines:
  main:
    input: seriatim
    references:
      pipeline-only: ./pipeline.txt
      shared: ./pipeline-shared.txt
    chunk:
      module: generic
      references:
        chunk-only: ./chunk.txt
    artifacts:
      spells:
        references:
          lane-only: ./lane.txt
          shared: ./lane-shared.txt
          overridden: ./lane.txt
        extract:
          module: dnd/spells
          references:
            extract-only: ./extract.txt
            overridden: ./extract-overridden.txt
        merge:
          module: appendorder
          references:
            merge-only: ./merge.txt
        normalize:
          module: noop
          references:
            normalize-only: ./normalize.txt
`)
	profile := cfg.Pipelines["main"]
	if !reflect.DeepEqual(profile.References, pipeline.ExternalReferenceMap(map[string]string{
		"pipeline-only": "./pipeline.txt",
		"shared":        "./pipeline-shared.txt",
	})) {
		t.Fatalf("pipeline references = %#v", profile.References)
	}
	if !reflect.DeepEqual(profile.Chunk.References, pipeline.ExternalReferenceMap(map[string]string{"chunk-only": "./chunk.txt"})) {
		t.Fatalf("chunk references = %#v", profile.Chunk.References)
	}
	lane := profile.Artifacts["spells"]
	if !reflect.DeepEqual(lane.References, pipeline.ExternalReferenceMap(map[string]string{
		"lane-only":  "./lane.txt",
		"shared":     "./lane-shared.txt",
		"overridden": "./lane.txt",
	})) {
		t.Fatalf("lane compatibility references = %#v", lane.References)
	}
	if !reflect.DeepEqual(lane.Extract.References, pipeline.ExternalReferenceMap(map[string]string{
		"lane-only":    "./lane.txt",
		"shared":       "./lane-shared.txt",
		"overridden":   "./extract-overridden.txt",
		"extract-only": "./extract.txt",
	})) {
		t.Fatalf("extract references = %#v", lane.Extract.References)
	}
	if !reflect.DeepEqual(lane.Merge.References, pipeline.ExternalReferenceMap(map[string]string{"merge-only": "./merge.txt"})) ||
		!reflect.DeepEqual(lane.Normalize.References, pipeline.ExternalReferenceMap(map[string]string{"normalize-only": "./normalize.txt"})) {
		t.Fatalf("merge/normalize references = %#v, %#v", lane.Merge.References, lane.Normalize.References)
	}
}

func TestFileConfigStageLocalValidatorsPreserveOrderAndFields(t *testing.T) {
	cfg := applyFileConfig(t, `version: 4
pipelines:
  main:
    input: seriatim
    chunk:
      module: generic
      validators:
        - generic/always_accept
        - module: generic/valid_json
          llm_profile: validator-profile
          options:
            schema: compact
    artifacts:
      spells:
        extract:
          module: dnd/spells
          validators:
            - module: extract/dnd/spells/shape
              options:
                strict: true
        merge:
          module: appendorder
          validators:
            - generic/always_accept
        normalize:
          module: noop
          validators:
            - module: generic/valid_json
              options:
                mode: normalized
`)
	profile := cfg.Pipelines["main"]
	chunkValidators := profile.Chunk.Validators.Validators
	if !profile.Chunk.Validators.Set || len(chunkValidators) != 2 || chunkValidators[0].Module != "generic/always_accept" ||
		chunkValidators[1].Module != "generic/valid_json" || chunkValidators[1].LLMProfile != "validator-profile" ||
		!reflect.DeepEqual(chunkValidators[1].Options, map[string]any{"schema": "compact"}) {
		t.Fatalf("chunk validators = %#v", profile.Chunk.Validators)
	}
	lane := profile.Artifacts["spells"]
	if len(lane.Extract.Validators.Validators) != 1 || lane.Extract.Validators.Validators[0].Module != "extract/dnd/spells/shape" ||
		!reflect.DeepEqual(lane.Extract.Validators.Validators[0].Options, map[string]any{"strict": true}) {
		t.Fatalf("extract validators = %#v", lane.Extract.Validators)
	}
	if len(lane.Merge.Validators.Validators) != 1 || lane.Merge.Validators.Validators[0].Module != "generic/always_accept" {
		t.Fatalf("merge validators = %#v", lane.Merge.Validators)
	}
	if len(lane.Normalize.Validators.Validators) != 1 || lane.Normalize.Validators.Validators[0].Module != "generic/valid_json" ||
		!reflect.DeepEqual(lane.Normalize.Validators.Validators[0].Options, map[string]any{"mode": "normalized"}) {
		t.Fatalf("normalize validators = %#v", lane.Normalize.Validators)
	}
}

func TestFileConfigStateSectionsApplyIndependently(t *testing.T) {
	cfg := applyFileConfig(t, `version: 4
promptkit:
  profile_dir: ./profiles
concurrency:
  total_llm: 7
output:
  directory: ./output
cache:
  chunk_plans:
    directory: ./plans
    mode: bypass
  checkpoints:
    enabled: true
    directory: ./checkpoints
debug:
  directory: ./debug
`)
	if cfg.PromptKit.ProfileDir != "./profiles" || cfg.PromptKit.ProfileFile != "" {
		t.Fatalf("promptkit = %#v", cfg.PromptKit)
	}
	if cfg.Concurrency.TotalLLM != 7 || cfg.Concurrency.StageWorkers["extract"] != 7 {
		t.Fatalf("concurrency = %#v", cfg.Concurrency)
	}
	if cfg.Output.Directory != "./output" || cfg.Cache.ChunkPlans.Directory != "plans" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass ||
		!cfg.Cache.Checkpoints.Enabled || cfg.Cache.Checkpoints.Directory != "checkpoints" || cfg.Debug.Directory != "./debug" {
		t.Fatalf("state sections = %#v, %#v, %#v, %#v", cfg.Output, cfg.Cache, cfg.Debug, cfg.PromptKit)
	}
	if cfg.Output.Directory == cfg.Cache.ChunkPlans.Directory || cfg.Cache.ChunkPlans.Directory == cfg.Cache.Checkpoints.Directory || cfg.Cache.Checkpoints.Directory == cfg.Debug.Directory {
		t.Fatal("state roots were coupled")
	}
}

func TestFileConfigCheckpointEnabledCanBeExplicitlyDisabled(t *testing.T) {
	cfg := applyFileConfig(t, "version: 4\ncache:\n  checkpoints:\n    enabled: true\n")
	if !cfg.Cache.Checkpoints.Enabled || !cloneConfig(cfg).Cache.Checkpoints.Enabled {
		t.Fatalf("enabled checkpoint config was not retained: %#v", cfg.Cache.Checkpoints)
	}
	file := parseFileConfig(t, "version: 4\ncache:\n  checkpoints:\n    enabled: false\n")
	if err := cfg.ApplyFileConfig(file); err != nil {
		t.Fatal(err)
	}
	if cfg.Cache.Checkpoints.Enabled {
		t.Fatalf("explicit false checkpoint config was not applied: %#v", cfg.Cache.Checkpoints)
	}
}

func TestFileConfigRejectsTrimmedKeyCollisions(t *testing.T) {
	tests := []struct {
		name string
		yaml string
		want string
	}{
		{
			name: "pipeline ids",
			yaml: "version: 4\npipelines:\n  main: {}\n  ' main ': {}\n",
			want: "pipeline id \"main\" is duplicated after trimming",
		},
		{
			name: "lane ids",
			yaml: "version: 4\npipelines:\n  main:\n    artifacts:\n      spells: {}\n      ' spells ': {}\n",
			want: "artifact lane id \"spells\" is duplicated after trimming",
		},
		{
			name: "reference slots",
			yaml: "version: 4\npipelines:\n  main:\n    references:\n      slot: ./one.txt\n      ' slot ': ./two.txt\n",
			want: "reference slot \"slot\" is duplicated after trimming",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			file := parseFileConfig(t, tt.yaml)
			cfg := Default()
			err := cfg.ApplyFileConfig(file)
			if err == nil || !strings.Contains(err.Error(), tt.want) {
				t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
			}
		})
	}
}

func TestFileConfigParsesOrderedStepsAndReferenceSources(t *testing.T) {
	file := parseFileConfig(t, `version: 4
pipelines:
  session:
    input: seriatim
    steps:
      - id: identify-npcs
        artifacts:
          npc_registry:
            extract: dnd/npc-registry
      - id: grounded-events
        references:
          npc_registry:
            artifact:
              step: identify-npcs
              lane: npc_registry
        artifacts:
          spells:
            extract: dnd/spells
`)
	cfg := Default()
	if err := cfg.ApplyFileConfig(file); err != nil {
		t.Fatal(err)
	}
	profile := cfg.Pipelines["session"]
	if len(profile.Steps) != 2 || profile.Steps[0].ID != "identify-npcs" || profile.Steps[1].ID != "grounded-events" {
		t.Fatalf("steps = %#v", profile.Steps)
	}
	source := profile.Steps[1].References["npc_registry"]
	if source.Artifact == nil || source.Artifact.Step != "identify-npcs" || source.Artifact.Lane != "npc_registry" {
		t.Fatalf("generated source = %#v", source)
	}
}

func TestFileConfigRejectsAmbiguousReferenceSourceForms(t *testing.T) {
	for _, source := range []string{
		"artifact: {step: a, lane: b, extra: c}",
		"artifact: {step: 1, lane: b}",
		"1",
	} {
		_, err := ParseFileConfigYAML([]byte("version: 4\npipelines:\n  p:\n    input: text\n    references:\n      slot: " + source + "\n"))
		if err == nil {
			t.Fatalf("ParseFileConfigYAML(%q) error = nil", source)
		}
	}
}

func TestFileConfigRejectsEmptyAndAmbiguousPipelineShapes(t *testing.T) {
	tests := []struct {
		name string
		yaml string
		want string
	}{
		{
			name: "empty steps",
			yaml: "version: 4\npipelines:\n  p:\n    input: text\n    steps: []\n",
			want: "at least one ordered step",
		},
		{
			name: "both forms",
			yaml: "version: 4\npipelines:\n  p:\n    input: text\n    artifacts: {}\n    steps: []\n",
			want: "both artifacts and steps",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			file := parseFileConfig(t, tt.yaml)
			cfg := Default()
			err := cfg.ApplyFileConfig(file)
			if err == nil || !strings.Contains(err.Error(), tt.want) {
				t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
			}
		})
	}
}

func TestLoadFileConfigReportsPathAndOperationContext(t *testing.T) {
	dir := t.TempDir()
	missing := filepath.Join(dir, "missing.yml")
	_, err := LoadFileConfig(missing)
	if err == nil || !strings.Contains(err.Error(), "read config file") || !strings.Contains(err.Error(), missing) {
		t.Fatalf("missing-file error = %v", err)
	}

	malformed := filepath.Join(dir, "malformed.yml")
	if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
		t.Fatal(err)
	}
	_, err = LoadFileConfig(malformed)
	if err == nil || !strings.Contains(err.Error(), "parse config file") || !strings.Contains(err.Error(), malformed) {
		t.Fatalf("malformed-file error = %v", err)
	}
}

func parseFileConfig(t *testing.T, source string) FileConfig {
	t.Helper()
	file, err := ParseFileConfigYAML([]byte(source))
	if err != nil {
		t.Fatalf("ParseFileConfigYAML() error = %v", err)
	}
	return file
}

func applyFileConfig(t *testing.T, source string) Config {
	t.Helper()
	cfg := Default()
	if err := cfg.ApplyFileConfig(parseFileConfig(t, source)); err != nil {
		t.Fatalf("ApplyFileConfig() error = %v", err)
	}
	return cfg
}
