package config

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"gopkg.in/yaml.v3"

	"textmachine/backend/internal/llm"
)

// echo_mine_test.go is the §2 regression gate for the DeepSeek echo mine: on a
// provider empirically shown to ECHO the untranslated CJK source when thinking is
// OFF (traced 2026-07-04), no model may be configured to disable/suppress thinking
// at reasoning=off — that re-arms a silent refusal (HTTP 200, no translation) in
// prod. LoadModels must fail-fast on both injection surfaces, while leaving the
// SAFE GLM editor (thinking:disabled over a Russian draft) untouched.
//
// Mutation check (how this guards against a future edit re-arming the mine):
// delete the echoMineViolation call in LoadModels (or the field) and the four
// "armed" cases below stop failing — the test goes red exactly when the guard is
// removed.

// writeAndLoadModels materializes a models.yaml body and runs it through the real
// LoadModels validation (mirrors the config_test.go temp-file pattern).
func writeAndLoadModels(t *testing.T, body string) (*Models, error) {
	t.Helper()
	dir := t.TempDir()
	path := filepath.Join(dir, "models.yaml")
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatal(err)
	}
	return LoadModels(path)
}

func TestDeepSeekEchoMineRejected(t *testing.T) {
	today := time.Now().UTC().Format("2006-01-02")
	// provCaps is injected under provider p; modelCaps under model m.
	tmpl := func(echoProne bool, modelExtra string) string {
		flag := ""
		if echoProne {
			flag = "\n    echoes_when_thinking_off: true"
		}
		return fmt.Sprintf(`
prices_checked: %q
default_model: m
providers:
  p:
    kind: openai
    base_url: http://x%s
models:
  m:
    provider: p
    price: { input_per_m: 1, output_per_m: 2 }%s
`, today, flag, modelExtra)
	}

	cases := []struct {
		name       string
		echoProne  bool
		modelExtra string
		wantErr    bool
	}{
		{
			// The exact traced mine: the thinking-disable switch via capabilities.
			name:       "armed via capabilities.reasoning.extra_body_disable",
			echoProne:  true,
			modelExtra: "\n    capabilities: { reasoning: { control: extra_body_disable, off_extra_body: { thinking: { type: disabled } } } }",
			wantErr:    true,
		},
		{
			// The second surface: a thinking-disable smuggled through extra_body,
			// which openAIRequest merges into the wire body — the control-only guard
			// would miss this, so it must be caught too.
			name:       "armed via extra_body thinking key",
			echoProne:  true,
			modelExtra: "\n    extra_body: { thinking: { type: disabled } }",
			wantErr:    true,
		},
		{
			// The NESTED form: chat_template_kwargs.thinking:false is DeepSeek's
			// documented V3.1+ disable — the most likely real arming form. A flat
			// top-level scan misses it; the recursive scan must catch it (ext-review).
			name:       "armed via nested chat_template_kwargs.thinking (DeepSeek V3.1+)",
			echoProne:  true,
			modelExtra: "\n    extra_body: { chat_template_kwargs: { thinking: false } }",
			wantErr:    true,
		},
		{
			// reasoning_effort at off also pushes thinking off the default.
			name:       "armed via capabilities.reasoning.effort",
			echoProne:  true,
			modelExtra: "\n    capabilities: { reasoning: { control: effort, off_effort: minimal } }",
			wantErr:    true,
		},
		{
			// The SAFE GLM case: an IDENTICAL thinking-disable is legal on a provider
			// that is NOT echo-prone (its editor input is a Russian draft).
			name:       "safe: same thinking-disable on a non-echo-prone provider",
			echoProne:  false,
			modelExtra: "\n    capabilities: { reasoning: { control: extra_body_disable, off_extra_body: { thinking: { type: disabled } } } }",
			wantErr:    false,
		},
		{
			// The current benign DeepSeek state: no reasoning capability → off is a
			// no-op → thinking stays at the provider default (ON).
			name:       "safe: echo-prone provider with the default (no) reasoning control",
			echoProne:  true,
			modelExtra: "",
			wantErr:    false,
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			_, err := writeAndLoadModels(t, tmpl(c.echoProne, c.modelExtra))
			if c.wantErr {
				if err == nil || !strings.Contains(err.Error(), "echo mine") {
					t.Fatalf("expected fail-fast mentioning the echo mine, got %v", err)
				}
				return
			}
			if err != nil {
				t.Fatalf("expected the config to load, got %v", err)
			}
		})
	}
}

// TestBoevoyConfigDeepSeekThinkingStaysOn guards the REAL configs/models.yaml
// against a future edit arming the mine. It asserts the three load-bearing facts
// directly (unmarshalling raw to dodge the prices_checked staleness date-bomb that
// LoadModels enforces): (1) the deepseek provider is still marked echo-prone — the
// guard is inert without the flag; (2) deepseek-v4-flash resolves to ReasoningNone
// so reasoning="off" stays an off-by-omission (thinking ON by default); (3)
// echoMineViolation is empty. Arm the real config and (2)/(3) go red here, while
// tmctl itself fails loudly via LoadModels.
func TestBoevoyConfigDeepSeekThinkingStaysOn(t *testing.T) {
	raw, err := os.ReadFile(filepath.Join("..", "..", "configs", "models.yaml"))
	if err != nil {
		t.Fatalf("read boevoy models.yaml: %v", err)
	}
	var m Models
	if err := yaml.Unmarshal(raw, &m); err != nil {
		t.Fatalf("parse boevoy models.yaml: %v", err)
	}

	const model = "deepseek-v4-flash"
	mod, ok := m.Models[model]
	if !ok {
		t.Fatalf("boevoy config no longer defines %s — update this guard deliberately", model)
	}
	prov := m.Providers[mod.Provider]
	if !prov.EchoesWhenThinkingOff {
		t.Fatalf("provider %s must stay marked echoes_when_thinking_off — the echo-mine guard is inert without it", mod.Provider)
	}
	if got := m.ResolveCapability(model).Reasoning.Control; got != llm.ReasoningNone {
		t.Fatalf("%s must resolve to ReasoningNone so reasoning=off stays a no-op (thinking ON), got %q — echo mine armed", model, got)
	}
	if why := m.echoMineViolation(model); why != "" {
		t.Fatalf("%s re-arms the echo mine: %s", model, why)
	}
}

// TestBoevoyConfigEditorGlm5AndGrokReasoning pins two invariants. (1) The D30.1 flip: the
// default editor of both boevoy pipelines is glm-5 BILINGUAL (mono-editor was passive and
// blind, exp12; grok reasoning-off is REMOVED from editor roles — a no-op) and its reasoning
// is off. (2) grok-4.3 (still the channel-B tier / 18+ judge in models.yaml) resolves to an
// EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT off-by-omission
// — grok's OMITTED default is "low" (it thinks, billed additively per xAI), and it must not
// trip the echo-mine guard. c2's select (judge) stage stays the glm-5 placeholder.
func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) {
	m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
	if err != nil {
		t.Fatalf("load boevoy models.yaml: %v", err)
	}
	// (2) grok-4.3 reasoning invariant (channel B / 18+ judge; unchanged by the editor flip).
	const grok = "grok-4.3"
	cap := m.ResolveCapability(grok)
	if cap.Reasoning.Control != llm.ReasoningEffortField || cap.Reasoning.OffEffort != "none" {
		t.Errorf("%s must resolve to explicit reasoning_effort:\"none\" (control=effort, off_effort=none), got control=%q off_effort=%q — control:none leaves grok's \"low\" default (thinks, additive billing)",
			grok, cap.Reasoning.Control, cap.Reasoning.OffEffort)
	}
	if why := m.echoMineViolation(grok); why != "" {
		t.Errorf("%s must not trip the echo-mine guard (xAI editor input is a Russian draft, not CJK): %s", grok, why)
	}
	// (1) D30.1 editor flip: glm-5 bilingual editor, reasoning off, in both boevoy pipelines.
	for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} {
		p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m)
		if err != nil {
			t.Fatalf("load %s: %v", pf, err)
		}
		var editModel, editReasoning, selectModel string
		for _, st := range p.Stages {
			switch st.Role {
			case "editor":
				editModel, editReasoning = st.Model, st.Reasoning
			case "judge":
				selectModel = st.Model
			}
		}
		if editModel != "glm-5" {
			t.Errorf("%s edit stage model = %q, want glm-5 (D30.1: bilingual editor; grok reasoning-off из редакторов СНЯТ — no-op)", pf, editModel)
		}
		if editReasoning != "off" {
			t.Errorf("%s edit stage reasoning = %q, want off (glm-5 thinking:disabled — таймауты ×3)", pf, editReasoning)
		}
		if pf == "pipeline-c2.yaml" && selectModel != "glm-5" {
			t.Errorf("%s select (judge) stage must stay glm-5 (Gemini Phase-2 slot), got %q", pf, selectModel)
		}
	}
}
