package configrefresh_test

import (
	"context"
	"testing"

	"iop/apps/edge/internal/configrefresh"
)

// TestClassifyListenPortRestartRequired verifies S08: listen port change is classified as restart_required.
func TestClassifyListenPortRestartRequired(t *testing.T) {
	dir := t.TempDir()

	currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
	candidateYAML := `
server:
  listen: "0.0.0.0:19090"
nodes:
  - id: "node-1"
    alias: "n1"
    token: "tok-1"
    adapters:
      ollama:
        enabled: true
        base_url: "http://127.0.0.1:11434"
    providers:
      - id: "prov-a"
        type: "ollama"
        category: "local_inference"
        adapter: "ollama"
        models: ["llama3.1"]
        capacity: 2
        max_queue: 4
        queue_timeout_ms: 5000
 `
	candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)

	current := buildNormalizedCurrent(t, currentPath)

	ctx := context.Background()
	result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
		Mode:       configrefresh.ModeDryRun,
		ConfigPath: candidatePath,
		RequestID:  "test-s08",
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}
	if result.Status != configrefresh.StatusRestartRequired {
		t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRestartRequired, result.Status, result.Summary)
	}
	found := false
	for _, c := range result.Changes {
		if c.Path == "server.listen" && c.Class == configrefresh.StatusRestartRequired {
			found = true
		}
	}
	if !found {
		t.Errorf("server.listen restart_required change not found in: %+v", result.Changes)
	}
}

// TestClassifyStreamEvidenceGateEnabledRestartRequired verifies that toggling
// openai.stream_evidence_gate.enabled is classified as restart_required: the
// request runtime owns the response lifecycle for the whole serve process and
// cannot be swapped live.
func TestClassifyStreamEvidenceGateEnabledRestartRequired(t *testing.T) {
	dir := t.TempDir()

	openAIDisabled := `
openai:
  enabled: true
  listen: "127.0.0.1:8081"
  provider_id: "test-provider"
  stream_evidence_gate:
    enabled: false
`
	openAIEnabled := `
openai:
  enabled: true
  listen: "127.0.0.1:8081"
  provider_id: "test-provider"
  stream_evidence_gate:
    enabled: true
`
	currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML+openAIDisabled)
	candidatePath := writeYAML(t, dir, "candidate.yaml", baseEdgeYAML+openAIEnabled)

	current := buildNormalizedCurrent(t, currentPath)

	ctx := context.Background()
	result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
		Mode:       configrefresh.ModeDryRun,
		ConfigPath: candidatePath,
		RequestID:  "test-stream-gate-enabled",
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}
	if result.Status != configrefresh.StatusRestartRequired {
		t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRestartRequired, result.Status, result.Summary)
	}
	found := false
	for _, c := range result.Changes {
		if c.Path == "openai" && c.Class == configrefresh.StatusRestartRequired {
			found = true
		}
	}
	if !found {
		t.Errorf("openai restart_required change not found in: %+v", result.Changes)
	}
}

// TestRefreshInvalidYAMLRejected verifies S09: invalid YAML is classified as rejected.
func TestRefreshInvalidYAMLRejected(t *testing.T) {
	dir := t.TempDir()
	currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
	invalidPath := writeYAML(t, dir, "invalid.yaml", "nodes: [\n  - bad: [unterminated")

	current := buildNormalizedCurrent(t, currentPath)

	ctx := context.Background()
	result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
		Mode:       configrefresh.ModeDryRun,
		ConfigPath: invalidPath,
		RequestID:  "test-s09-yaml",
	})
	if err != nil {
		t.Fatalf("Evaluate should not return error for rejected config: %v", err)
	}
	if result.Status != configrefresh.StatusRejected {
		t.Errorf("expected status=%q, got %q", configrefresh.StatusRejected, result.Status)
	}
	if candidate != nil {
		t.Errorf("expected nil candidate for rejected result")
	}
}

// TestClassifyServeNormalizedRefreshApplied verifies the archived bug:
// when current config is serve-normalized (logging.path absolute,
// bootstrap.artifact_dir absolute) and candidate uses source-level defaults
// (empty logging.path, relative bootstrap.artifact_dir) with only provider
// capacity changes, the result must be applied, NOT restart_required.
func TestClassifyServeNormalizedRefreshApplied(t *testing.T) {
	dir := t.TempDir()

	// Source-level YAML with empty logging.path and relative artifact_dir.
	// Only capacity differs (2 → 8).
	sourceYAML := `
server:
  listen: "0.0.0.0:9090"
bootstrap:
  artifact_dir: artifacts
logging:
  level: info
nodes:
  - id: "node-1"
    alias: "n1"
    token: "tok-1"
    adapters:
      ollama:
        enabled: true
        base_url: "http://127.0.0.1:11434"
    providers:
      - id: "prov-a"
        type: "ollama"
        category: "local_inference"
        adapter: "ollama"
        models: ["llama3.1"]
        capacity: 2
        max_queue: 4
        queue_timeout_ms: 5000
 `
	// Candidate: only capacity changes to 8; logging/artifact_dir are same source-level defaults.
	candidateYAML := `
server:
  listen: "0.0.0.0:9090"
bootstrap:
  artifact_dir: artifacts
logging:
  level: info
nodes:
  - id: "node-1"
    alias: "n1"
    token: "tok-1"
    adapters:
      ollama:
        enabled: true
        base_url: "http://127.0.0.1:11434"
    providers:
      - id: "prov-a"
        type: "ollama"
        category: "local_inference"
        adapter: "ollama"
        models: ["llama3.1"]
        capacity: 8
        max_queue: 4
        queue_timeout_ms: 5000
 `

	currentPath := writeYAML(t, dir, "current.yaml", sourceYAML)
	candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)

	// Build current as serve-normalized: explicit absolute paths.
	current := buildNormalizedCurrent(t, currentPath)

	// Verify that the current config actually has normalized paths.
	if current.Logging.Path == "" {
		t.Fatalf("expected normalized logging.path, got empty")
	}
	if current.Bootstrap.ArtifactDir == "" {
		t.Fatalf("expected normalized bootstrap.artifact_dir, got empty")
	}

	// Evaluate the candidate via LoadCandidate (which now applies normalization).
	ctx := context.Background()
	result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
		Mode:       configrefresh.ModeDryRun,
		ConfigPath: candidatePath,
		RequestID:  "test-serve-normalized",
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	// Key assertion: capacity-only refresh must NOT be restart_required.
	if result.Status != configrefresh.StatusApplied {
		t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
	}

	// Ensure no restart_required changes for logging or bootstrap.artifact_dir.
	for _, c := range result.Changes {
		if c.Class == configrefresh.StatusRestartRequired {
			t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
		}
	}

	// Verify the capacity change is captured as applied.
	foundCapacity := false
	for _, c := range result.Changes {
		if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
			foundCapacity = true
			if c.Previous != "2" || c.Next != "8" {
				t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
			}
		}
	}
	if !foundCapacity {
		t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
	}

	// Verify the candidate is also normalized.
	if candidate == nil {
		t.Fatalf("expected non-nil candidate")
	}
	if candidate.Logging.Path == "" {
		t.Fatalf("expected normalized candidate logging.path, got empty")
	}
}

// TestClassifyExplicitRelativeLoggingPathNoRestartRequired verifies that when
// both current and candidate use the same explicit relative logging.path
// (e.g. "./logs/edge.log") and only provider capacity differs, the refresh
// is classified as applied – NOT restart_required.
//
// This is the regression test for the archived bug where applyRuntimeNormalization
// called filepath.Clean on explicit relative paths, producing a false diff
// against the serve-side value that preserves "./logs/edge.log" unchanged.
func TestClassifyExplicitRelativeLoggingPathNoRestartRequired(t *testing.T) {
	dir := t.TempDir()

	// Current config with explicit relative logging.path – mimics what serve
	// holds in memory after loading YAML with logging.path: "./logs/edge.log".
	currentYAML := `
server:
  listen: "0.0.0.0:9090"
logging:
  path: "./logs/edge.log"
  level: info
nodes:
  - id: "node-1"
    alias: "n1"
    token: "tok-1"
    adapters:
      ollama:
        enabled: true
        base_url: "http://127.0.0.1:11434"
    providers:
      - id: "prov-a"
        type: "ollama"
        category: "local_inference"
        adapter: "ollama"
        models: ["llama3.1"]
        capacity: 2
        max_queue: 4
        queue_timeout_ms: 5000
 `

	// Candidate: same explicit relative logging.path, only capacity changes.
	candidateYAML := `
server:
  listen: "0.0.0.0:9090"
logging:
  path: "./logs/edge.log"
  level: info
nodes:
  - id: "node-1"
    alias: "n1"
    token: "tok-1"
    adapters:
      ollama:
        enabled: true
        base_url: "http://127.0.0.1:11434"
    providers:
      - id: "prov-a"
        type: "ollama"
        category: "local_inference"
        adapter: "ollama"
        models: ["llama3.1"]
        capacity: 8
        max_queue: 4
        queue_timeout_ms: 5000
 `

	currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
	candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)

	// buildNormalizedCurrent preserves the explicit relative logging.path.
	current := buildNormalizedCurrent(t, currentPath)

	// Verify the current config still has the explicit relative path.
	if current.Logging.Path != "./logs/edge.log" {
		t.Fatalf("expected current logging.path to remain './logs/edge.log', got %q", current.Logging.Path)
	}

	ctx := context.Background()
	result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
		Mode:       configrefresh.ModeDryRun,
		ConfigPath: candidatePath,
		RequestID:  "test-explicit-relative-logpath",
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	// Key assertion: capacity-only refresh with same explicit relative path must be applied.
	if result.Status != configrefresh.StatusApplied {
		t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
	}

	// Ensure no restart_required changes at all.
	for _, c := range result.Changes {
		if c.Class == configrefresh.StatusRestartRequired {
			t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
		}
	}

	// Verify the capacity change is captured as applied.
	foundCapacity := false
	for _, c := range result.Changes {
		if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
			foundCapacity = true
			if c.Previous != "2" || c.Next != "8" {
				t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
			}
		}
	}
	if !foundCapacity {
		t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
	}

	// Candidate should also preserve the explicit relative path.
	if candidate == nil {
		t.Fatalf("expected non-nil candidate")
	}
	if candidate.Logging.Path != "./logs/edge.log" {
		t.Errorf("expected candidate logging.path to remain './logs/edge.log', got %q", candidate.Logging.Path)
	}
}

// TestClassifyDryRunTwiceNoDiff verifies that after apply, re-running dry-run
// on the same candidate produces no diff.
func TestClassifyDryRunTwiceNoDiff(t *testing.T) {
	dir := t.TempDir()

	baseYAML := `
server:
  listen: "0.0.0.0:9090"
nodes:
  - id: "node-1"
    alias: "n1"
    token: "tok-1"
    adapters:
      ollama:
        enabled: true
        base_url: "http://127.0.0.1:11434"
    providers:
      - id: "prov-a"
        type: "ollama"
        category: "local_inference"
        adapter: "ollama"
        models: ["llama3.1"]
        capacity: 2
 `
	candidateYAML := `
server:
  listen: "0.0.0.0:9090"
nodes:
  - id: "node-1"
    alias: "n1"
    token: "tok-1"
    adapters:
      ollama:
        enabled: true
        base_url: "http://127.0.0.1:11434"
    providers:
      - id: "prov-a"
        type: "ollama"
        category: "local_inference"
        adapter: "ollama"
        models: ["llama3.1"]
        capacity: 4
 `

	currentPath := writeYAML(t, dir, "current.yaml", baseYAML)
	candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
	current := buildNormalizedCurrent(t, currentPath)

	ctx := context.Background()

	// First dry-run: should detect capacity change.
	result1, cand1, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
		Mode:       configrefresh.ModeDryRun,
		ConfigPath: candidatePath,
		RequestID:  "test-first-dryrun",
	})
	if err != nil {
		t.Fatalf("first Evaluate: %v", err)
	}
	if result1.Status != configrefresh.StatusApplied {
		t.Fatalf("first dry-run: expected status=%q, got %q", configrefresh.StatusApplied, result1.Status)
	}

	// Second dry-run with the same candidate loaded again.
	// The candidate path still exists, so LoadCandidate re-reads the same YAML.
	result2, cand2, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
		Mode:       configrefresh.ModeDryRun,
		ConfigPath: candidatePath,
		RequestID:  "test-second-dryrun",
	})
	if err != nil {
		t.Fatalf("second Evaluate: %v", err)
	}

	// Both results should be identical (same changes).
	if len(result1.Changes) != len(result2.Changes) {
		t.Fatalf("expected same number of changes, got %d vs %d: result1=%+v result2=%+v",
			len(result1.Changes), len(result2.Changes), result1.Changes, result2.Changes)
	}

	// Both candidates should be normalized identically.
	if cand1 == nil || cand2 == nil {
		t.Fatalf("expected non-nil candidates")
	}
	if cand1.Logging.Path != cand2.Logging.Path {
		t.Fatalf("candidate logging.path mismatch: %s vs %s", cand1.Logging.Path, cand2.Logging.Path)
	}
}
