package pipeline

import (
	"context"
	"fmt"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// maxtokens_floor_test.go pins the per-model max_tokens FLOOR (D24.3,
// capabilities.min_max_tokens). The golden fixture uses only unfloored fake models,
// so it does NOT exercise this behaviour — these dedicated tests do, mutation-verified:
//   - the floor lifts a small derived budget on the PRIMARY call (revert the stagerun
//     applyModelFloor line and TestMinMaxTokensFloorPrimary goes red);
//   - the escalation hop re-floors against ITS OWN model, not the inherited primary
//     budget (revert the maybeEscalate hopMaxTokens line and TestMinMaxTokensFloorHop
//     goes red — the exact stage-A bug where draft→deepseek-v4-pro hops inherited
//     2048/3291 and returned finish=length);
//   - editing a floor is a loud --resnapshot (the floor is folded into the snapshot via
//     the resolved Capability), so TestMinMaxTokensFloorFoldedIntoSnapshot proves the
//     snapshotID moves when only the floor changes (invariant #2).

// setupFloorProject writes a ja→ru book whose draft model carries a per-model floor and,
// optionally, escalates to a floored fallback. Both models sit on the same fake provider;
// the mock distinguishes them by the "model" field. A tiny source keeps the derived base
// budget (EstimateTokens × ratio, floored at the small global min_max_tokens) well BELOW
// the per-model floors, so a wire max_tokens at the floor can only come from the floor.
func setupFloorProject(t *testing.T, providerURL string, draftFloor, hopFloor int, withEscalation bool) string {
	t.Helper()
	dir := t.TempDir()
	writeFile(t, filepath.Join(dir, "prompts", "translator.md"),
		"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
	writeFile(t, filepath.Join(dir, "prompts", "editor.md"),
		"Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}")

	draftCaps := ""
	if draftFloor > 0 {
		draftCaps = fmt.Sprintf("\n    capabilities: { min_max_tokens: %d }", draftFloor)
	}
	hopCaps := ""
	if hopFloor > 0 {
		hopCaps = fmt.Sprintf("\n    capabilities: { min_max_tokens: %d }", hopFloor)
	}
	writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
providers:
  fake:
    kind: openai
    base_url: %q
    timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
models:
  fake-model:
    provider: fake
    price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }%s
  fake-hop:
    provider: fake
    price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }%s
`, time.Now().UTC().Format("2006-01-02"), providerURL, draftCaps, hopCaps))

	escLine, escBudget := "", ""
	if withEscalation {
		escLine = ", escalate_to: fake-hop"
		escBudget = "escalation: { budget_usd: 1.0 }"
	}
	// A small global min_max_tokens (512) is the floor the model-level floors must beat.
	writeFile(t, filepath.Join(dir, "pipeline.yaml"), fmt.Sprintf(`
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 0 }
stages:
  - { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off"%s }
  - { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
%s
`, escLine, escBudget))

	writeFile(t, filepath.Join(dir, "source.txt"), "静かな朝。")
	writeFile(t, filepath.Join(dir, "book.yaml"), `
book_id: test-book
title: Тест
source_lang: ja
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: polivanov
footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 5.0, day_usd: 10.0 }
`)
	return filepath.Join(dir, "book.yaml")
}

// maxTokensForModel returns the max_tokens of the FIRST recorded body whose "model" is
// modelName (the wire proof — the floor is applied before the request_hash, so what the
// provider receives is the floored value).
func maxTokensForModel(t *testing.T, rec *reqRec, modelName string) int {
	t.Helper()
	for _, body := range rec.all() {
		if strings.Contains(body, `"model":"`+modelName+`"`) {
			return maxTokensOf(t, body)
		}
	}
	t.Fatalf("no recorded request for model %q", modelName)
	return 0
}

// The primary call's derived budget is lifted to the model's min_max_tokens floor.
func TestMinMaxTokensFloorPrimary(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, draftEdit)
	defer srv.Close()
	bookPath := setupFloorProject(t, srv.URL, 8000, 0, false)

	r := newRunner(t, bookPath)
	defer r.Close()
	if _, err := r.TranslateBook(context.Background()); err != nil {
		t.Fatal(err)
	}
	// The tiny source derives far below 8000 (and below the 512 global floor too), so a
	// wire max_tokens of exactly 8000 can only be the per-model floor.
	if got := maxTokensForModel(t, rec, "fake-model"); got != 8000 {
		t.Fatalf("primary draft wire max_tokens = %d, want the model floor 8000 (D24.3)", got)
	}
}

// The escalation hop is floored against ITS OWN model (fake-hop), NOT the smaller,
// floor-less primary budget it inherits — the stage-A fix. The primary draft stays at the
// small derived budget, proving the floor is per-call-model, not global.
func TestMinMaxTokensFloorHop(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if isEditBody(body) {
			return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
		}
		if strings.Contains(body, `"model":"fake-hop"`) {
			return "Тихое утро.", "stop" // the hop translates cleanly → the re-gate passes
		}
		return "静かな図書館の朝。", "stop" // the primary echoes CJK → cjk_artifact → escalate
	})
	defer srv.Close()
	// draft floor 0 (primary keeps the small derived budget), hop floor 9000.
	bookPath := setupFloorProject(t, srv.URL, 0, 9000, true)

	r := newRunner(t, bookPath)
	defer r.Close()
	res, err := r.TranslateBook(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if !res.Chunks[0].Stages[0].Escalated {
		t.Fatalf("premise broken: the primary must escalate for the hop budget to be observable, got %+v", res.Chunks[0].Stages[0])
	}
	// The hop's own floor (9000) reaches the wire...
	if got := maxTokensForModel(t, rec, "fake-hop"); got != 9000 {
		t.Fatalf("escalation hop wire max_tokens = %d, want the HOP model floor 9000 — a hop must re-floor against its own model, not inherit the primary budget (D24.3)", got)
	}
	// ...while the floor-less primary stays at the small derived budget (global floor 512),
	// so the hop's 9000 is not merely an inherited value.
	if got := maxTokensForModel(t, rec, "fake-model"); got != 512 {
		t.Fatalf("primary draft wire max_tokens = %d, want the small derived/global budget 512 (proves the hop floor is per-call, not shared)", got)
	}
}

// Editing a floor is a loud --resnapshot: the floor is folded into the snapshot via the
// resolved Capability (snapshot.go marshals ResolveCapability for the stage AND its hop),
// so a floor-only change must move the snapshotID — else a stale checkpoint false-hits a
// diverged wire request (invariant #2 / D5.2).
func TestMinMaxTokensFloorFoldedIntoSnapshot(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, draftEdit)
	defer srv.Close()

	snapFor := func(draftFloor int) string {
		r := newRunner(t, setupFloorProject(t, srv.URL, draftFloor, 0, false))
		defer r.Close()
		id, _, err := r.snapshotID()
		if err != nil {
			t.Fatal(err)
		}
		return id
	}
	none, f8000, f16000 := snapFor(0), snapFor(8000), snapFor(16000)
	if none == f8000 {
		t.Fatalf("adding a min_max_tokens floor must change the snapshotID (wire-affecting input, D24.3), got %s for both", none)
	}
	if f8000 == f16000 {
		t.Fatalf("changing the floor value must change the snapshotID, got %s for both", f8000)
	}
}
