package llm

// capability.go is the per-MODEL wire-shape layer (05-decisions-log D3.1).
// OpenAI-compatible providers share an ENDPOINT but not a BODY: Kimi accepts
// only temperature:1 (else 400), gpt-5-mini needs max_completion_tokens WITHOUT
// temperature (else 400), Gemini 3.1 Pro 400s if thinking is disabled. The
// Phase-0 wire — hardcoded max_tokens + always-send temperature — 400s on all
// three. Capability declares, per model (resolved from models.yaml: provider
// default merged with a per-model override, model wins), how to render
// budget / temperature / reasoning into the wire body.
//
// The resolver runs at openAIRequest marshal time so the neutral request stays
// vendor-free, and the resolved Capability is folded into the job snapshot
// (stageSnap) so editing a capability INVALIDATES checkpoints instead of
// silently false-hitting them — the same determinism class the snapshot payload
// closes (D5.2). Capability is the WIRE form; it is deliberately separate from
// Provider.Reasoning (subset|additive), which is a BILLING semantic feeding the
// ledger — a model can bill additively yet still disable thinking on the wire.

// BudgetField is the JSON key carrying the output-token cap.
type BudgetField string

const (
	// BudgetMaxTokens is the classic OpenAI field (DeepSeek, GLM, xAI, Kimi,
	// Gemini-compat, local).
	BudgetMaxTokens BudgetField = "max_tokens"
	// BudgetMaxCompletionTokens is the gpt-5 family field; sending max_tokens
	// there is rejected.
	BudgetMaxCompletionTokens BudgetField = "max_completion_tokens"
)

// TempMode decides whether and how temperature reaches the wire.
type TempMode string

const (
	// TempSend emits the request's temperature verbatim, INCLUDING an explicit
	// 0 — a deterministic role (judge) must not silently sample at the provider
	// default ~1.0 (the Phase-0 no-omitempty fix, now expressed per-model).
	TempSend TempMode = "send"
	// TempOmit never emits temperature (gpt-5 family: any temperature 400s).
	TempOmit TempMode = "omit"
	// TempForce always emits TempValue, ignoring the request (Kimi: only
	// temperature:1 is accepted).
	TempForce TempMode = "force"
)

// ReasoningControl maps the neutral ReasoningEffort ("" | off | low | medium |
// high) onto the wire.
type ReasoningControl string

const (
	// ReasoningNone is the OpenAI-compat baseline: low|medium|high go out as
	// reasoning_effort; off and "" emit nothing, leaving the provider's OWN default.
	// Use it for a model whose thinking must NOT be pushed off that default from our
	// side: DeepSeek-flash on the draft path defaults to thinking ON, and "off" here is
	// a deliberate NO-OP (emitting nothing keeps it ON) — DISABLING DeepSeek thinking
	// arms the echo mine (it returns the untranslated CJK source at HTTP 200;
	// config.echoMineViolation fail-fasts on it). This is the WRONG control for a model
	// whose thinking is ON BY OMISSION: xAI-Grok defaults reasoning_effort to "low" (it
	// thinks), so a role that must NOT think (the editor) uses ReasoningEffortField with
	// OffEffort "none" to send an EXPLICIT reasoning_effort:"none" instead.
	ReasoningNone ReasoningControl = "none"
	// ReasoningEffortField maps "off" to a fixed OffEffort value on the
	// reasoning_effort field (gpt-5: "minimal"; local ollama: "none").
	ReasoningEffortField ReasoningControl = "effort"
	// ReasoningExtraBodyDisable disables thinking by MERGING OffExtraBody into
	// the body (GLM {"thinking":{"type":"disabled"}}); for a think-ON escalation
	// (D6.2) it merges OnExtraBody. effort "" leaves the provider default.
	ReasoningExtraBodyDisable ReasoningControl = "extra_body_disable"
	// ReasoningMandatory means thinking CANNOT be disabled — any disable attempt
	// (reasoning_effort, thinking_budget:0) 400s (Gemini 3.1 Pro). The neutral
	// "off" is swallowed: nothing reasoning-related is emitted, the model's
	// forced thinking runs and MUST be budgeted as output ($12/M, D6.3).
	ReasoningMandatory ReasoningControl = "mandatory"
)

// ReasoningCap is the resolved reasoning wire-form for one model.
type ReasoningCap struct {
	Control      ReasoningControl
	OffEffort    string         // ReasoningEffortField: value sent when effort=="off"
	OffExtraBody map[string]any // ReasoningExtraBodyDisable: merged when effort=="off"
	OnExtraBody  map[string]any // ReasoningExtraBodyDisable: merged when thinking is on (D6.2)
}

// Capability is the fully-resolved wire shape for one model. Its zero value is
// the OpenAI-compat baseline (see withDefaults), so a model with no
// capabilities block and the direct-construction path in tests both reproduce
// the Phase-0 wire.
type Capability struct {
	Budget    BudgetField
	Temp      TempMode
	TempValue float64
	Reasoning ReasoningCap
	// MinMaxTokens is a per-model FLOOR on the derived max_tokens budget (D24.3):
	// a thinking model (DeepSeek/Gemini/Kimi) whose reasoning consumes the output
	// budget before any content is emitted returns finish=length or empty content
	// below a per-model minimum — acceptance stage A traced draft→deepseek-v4-pro
	// escalation hops receiving an inherited 2048/3291 and returning length. The
	// floor is NOT a wire body key (applyToBody never reads it); it is applied in
	// the runner's budget derivation BEFORE the request_hash, so it is wire-visible
	// via the resulting max_tokens. It lives on Capability so it is folded into the
	// job snapshot alongside the other wire-affecting inputs (snapshot.go marshals
	// the resolved Capability for the primary AND the escalate_to model) — editing
	// a floor is then a loud --resnapshot, not a silent false-hit. omitempty keeps a
	// floor-less model's snapshot byte-identical to before this field existed. 0 =
	// no floor (reasoning-off models: GLM, grok).
	MinMaxTokens int `json:",omitempty"`
}

// withDefaults fills the OpenAI-compat baseline for zero fields: max_tokens +
// always-send temperature + off-by-omission reasoning.
func (c Capability) withDefaults() Capability {
	if c.Budget == "" {
		c.Budget = BudgetMaxTokens
	}
	if c.Temp == "" {
		c.Temp = TempSend
	}
	if c.Reasoning.Control == "" {
		c.Reasoning.Control = ReasoningNone
	}
	return c
}

// applyToBody writes the budget / temperature / reasoning keys into the wire
// body map. effort is the request's neutral ReasoningEffort. Resolved standard
// keys are authoritative; config-supplied extra_body merges only into the gaps
// (see openAIRequest.MarshalJSON and mergeBody).
func (c Capability) applyToBody(m map[string]any, maxTokens int, temperature float64, effort string) {
	c = c.withDefaults()
	m[string(c.Budget)] = maxTokens

	switch c.Temp {
	case TempOmit:
		// never emitted
	case TempForce:
		m["temperature"] = c.TempValue
	default: // TempSend
		m["temperature"] = temperature
	}

	switch c.Reasoning.Control {
	case ReasoningMandatory:
		// swallow everything: a disable attempt 400s, forced thinking runs
	case ReasoningExtraBodyDisable:
		// A disable-capability's whole purpose is to keep thinking OFF: for GLM
		// the provider default IS thinking-on (the ×3-timeout blowup the switch
		// exists to prevent), so an UNSPECIFIED effort ("") defaults to disabled
		// exactly like "off". This preserves the Phase-0 unconditional-disable
		// semantics (extra_body merged on every call); only an explicit
		// low|medium|high opts into think-ON via OnExtraBody (D6.2). This is a
		// deliberate per-control reading of "" — it differs from ReasoningNone,
		// where "" means the provider default.
		if effort == "" || effort == "off" {
			mergeBody(m, c.Reasoning.OffExtraBody)
		} else {
			mergeBody(m, c.Reasoning.OnExtraBody)
		}
	case ReasoningEffortField:
		if effort == "off" {
			if c.Reasoning.OffEffort != "" {
				m["reasoning_effort"] = c.Reasoning.OffEffort
			}
		} else if effort != "" {
			m["reasoning_effort"] = effort
		}
	default: // ReasoningNone
		if effort != "" && effort != "off" {
			m["reasoning_effort"] = effort
		}
	}
}

// mergeBody copies src into dst without overwriting an existing key: config
// tuning fills gaps, it never overrides a resolved standard field.
func mergeBody(dst, src map[string]any) {
	for k, v := range src {
		if _, exists := dst[k]; !exists {
			dst[k] = v
		}
	}
}
