package config

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"
)

// The blocks nested under voice: the two worker seams, the embedder the
// classifier scores with, the weather provider and the act allowlist. They live
// here because none of them is reachable except through a voice block.

// WorkerConfig — a unix-socket worker module connection. Used by Stt and
// (via TtsConfig embedding the same fields) by Tts. Socket is the unix
// socket path the worker module listens on (e.g.
// /run/user/$UID/maven/stt.sock). Lang overrides the surface default for
// this module when the user wants different langs for stt vs tts (rare).
type WorkerConfig struct {
	Socket string `json:"socket,omitempty"`
	Lang   string `json:"lang,omitempty"`
}

// TtsConfig — the tts worker module connection + tts-specific Voice field
// (a named voice when the worker supports multiple; "" ⇒ the worker's
// configured default).
type TtsConfig struct {
	Socket string `json:"socket,omitempty"`
	Lang   string `json:"lang,omitempty"`
	Voice  string `json:"voice,omitempty"`
}

// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
// constructs an in-process ONNX embedder when all three paths are non-empty;
// the router's classifier then uses real sentence embeddings instead of the
// floor HashEmbedder stub. Model_path is the ONNX model file, tokenizer_path
// is tokenizer.json (Unigram), lib_path is the ONNX Runtime shared library.
type EmbedderConfig struct {
	ModelPath     string `json:"model_path,omitempty"`
	TokenizerPath string `json:"tokenizer_path,omitempty"`
	LibPath       string `json:"lib_path,omitempty"`

	// HeadsPath — the routing heads graph, which is a fine-tuned COPY of the
	// model above with four linear heads on its pooled output (V-664). Empty
	// means no heads, and the cascade runs exactly as it did before they
	// existed. It shares LibPath and TokenizerPath, and router_heads.json is
	// read from the same directory.
	//
	// It must never be pointed at ModelPath. Memory recall depends on the
	// resident copy scoring what it scored, and the fine-tuned one does not.
	HeadsPath string `json:"heads_path,omitempty"`
}

// WeatherConfig configures the weather provider for voice queries.
type WeatherConfig struct {
	Provider        string `json:"provider,omitempty"`         // "open-meteo" or "" → stub
	DefaultLocation string `json:"default_location,omitempty"` // e.g. "Moscow"
}

// ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is
// the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that
// must not fire from the voice path (they need a confirm on an authed surface).
//
// Aliases are the spoken phrases that reach this tool, Russian included. They
// are config data rather than a pattern in code, and they match as exact leading
// tokens, so an imperative reaches the tool and the past tense of the same verb
// does not.
type ToolConfig struct {
	Name        string   `json:"name"`
	Scope       string   `json:"scope,omitempty"`
	Cmd         []string `json:"cmd"`
	Destructive bool     `json:"destructive,omitempty"`
	Aliases     []string `json:"aliases,omitempty"`
}

// Voice defaults, applied in normaliseVoice.
const (
	DefaultRouterThreshold = 0.55
	// The absolute half of the recall gate, recalibrated after tokenizer rev 2
	// changed the e5 score distribution. At 0.80 with the independent 0.008
	// margin and structural eligibility, the held-out fixture answers 14/27
	// real recalls and 0/14 false ones. The score distributions still overlap
	// (true minimum 0.817, silent maximum 0.874), so structure remains decisive.
	DefaultQueryMinScore = 0.80
	// The runner-up half is intentionally separate. Keep 0.008 as the ambiguity
	// floor measured before the structural gate; a single-hit store has no
	// runner-up, which is why score and structure are both required.
	DefaultQueryMinMargin = 0.008
	// DefaultClarifyMaxAttempts — see dialogue.DefaultMaxAttempts.
	DefaultClarifyMaxAttempts = 3
	DefaultToolTimeout        = 30 * time.Second
	// DefaultLLMRouter — route with the resident model unless told otherwise.
	DefaultLLMRouter = true
)

// UseLLMRouter reports whether to route with the resident model. Unset means
// on; only an explicit false in the config turns it off.
func (v *VoiceConfig) UseLLMRouter() bool {
	if v == nil || v.LLMRouter == nil {
		return DefaultLLMRouter
	}
	return *v.LLMRouter
}

// normaliseVoice applies the block's defaults. An absent block stays nil: the
// surface is off and there is nothing to tune.
func (c *Config) normaliseVoice() {
	if c.Voice == nil {
		return
	}
	if c.Voice.RouterThreshold <= 0 {
		c.Voice.RouterThreshold = DefaultRouterThreshold
	}
	if c.Voice.QueryMinScore <= 0 {
		c.Voice.QueryMinScore = DefaultQueryMinScore
	}
	// Unset ⇒ default. Negative is how you turn the margin off on purpose,
	// so it is clamped to 0 rather than replaced by the default.
	switch {
	case c.Voice.QueryMinMargin == 0:
		c.Voice.QueryMinMargin = DefaultQueryMinMargin
	case c.Voice.QueryMinMargin < 0:
		c.Voice.QueryMinMargin = 0
	}
	if c.Voice.ClarifyMaxAttempts <= 0 {
		c.Voice.ClarifyMaxAttempts = DefaultClarifyMaxAttempts
	}
	if c.Voice.ToolTimeout <= 0 {
		c.Voice.ToolTimeout = Duration(DefaultToolTimeout)
	}
	if c.Voice.LLMRouter == nil {
		on := DefaultLLMRouter
		c.Voice.LLMRouter = &on
	}
}

// validateVoice refuses a surface that would listen nowhere, and an embedder
// block with only some of its three paths filled in.
func (c *Config) validateVoice() error {
	if c.Voice == nil || !c.Voice.Enabled {
		return nil
	}
	if c.Voice.Bind == "" {
		return errors.New("voice.enabled set but voice.bind is empty — refusing to start a voice surface with no bind address")
	}
	if e := c.Voice.Embedder; e != nil {
		if e.ModelPath == "" || e.TokenizerPath == "" || e.LibPath == "" {
			return errors.New("voice.embedder: all three of model_path, tokenizer_path, lib_path must be set, or remove embedder to use the floor stub")
		}
		if err := e.checkHeadsDistinct(); err != nil {
			return err
		}
	}
	return nil
}

// checkHeadsDistinct refuses a heads graph that is the embedder's own file
// (V-692). The rule is stated on HeadsPath above and in CLAUDE.md, and until
// now nothing enforced it: the daemon loaded whatever the key pointed at, so
// pointing both keys at one file cost recall with no error and no log line. It
// reads as ordinary drift, which is the worst kind of misconfiguration.
//
// Refusing to start is the right trade here. The heads are an accelerator and a
// broken weights file is deliberately not fatal in voicewire.go, but this is not
// a broken file. It is a working file in the wrong role, and a daemon that
// cannot route well should say so rather than answer worse.
//
// Cleaned and made absolute first, so "./m.onnx" and "$PWD/m.onnx" are one
// path. Then SameFile, which catches the copy that is a symlink or a hard link
// to the original. A path that does not stat is left to the loader, which fails
// on it with a better message than this can give.
func (e *EmbedderConfig) checkHeadsDistinct() error {
	if e.HeadsPath == "" || e.ModelPath == "" {
		return nil
	}
	heads, model := absClean(e.HeadsPath), absClean(e.ModelPath)
	same := heads == model
	if !same {
		hi, herr := os.Stat(heads)
		mi, merr := os.Stat(model)
		same = herr == nil && merr == nil && os.SameFile(hi, mi)
	}
	if same {
		return fmt.Errorf("voice.embedder: heads_path and model_path are the same file (%s) — the heads graph is a fine-tuned copy, and scoring recall with it degrades what the resident embedder already stored", heads)
	}
	return nil
}

// absClean — the comparable form of a path. Abs fails only when the working
// directory is unreadable, and a cleaned relative path is still worth comparing,
// so the error falls back rather than propagating.
func absClean(p string) string {
	if abs, err := filepath.Abs(p); err == nil {
		return abs
	}
	return filepath.Clean(p)
}

// VoiceConfig — the client↔core TCP surface + the stt/tts worker-module
// seams.
//
// Enabled gates wiring; Bind is the TCP address (inside the wg tunnel in
// production; "127.0.0.1:9100" for the local smoke). Lang is the default
// language hint passed to both stt and tts (per-call overrides later).
//
// Stt and Tts are the worker-module seams. nil Stt ⇒ daemon wires the
// in-process stt.Stub (the "no models on disk" floor — the loop is
// exercisable end-to-end with a deterministic no-model transcriber).
// non-nil Stt with Socket ⇒ daemon wires stt.Remote dialing that unix
// socket (cmd/mavsttd serves the other end; production swaps in a
// faster-whisper handler in cmd/mavsttd, no daemon or stt-package
// change). Tts mirrors for tts.Remote + cmd/mavttsd.
//
// Embedder configures the router's sentence embedder. When all three
// paths are set, the daemon constructs an ONNX multilingual embedder
// (in-process); when nil, it falls back to the floor HashEmbedder stub
// (deterministic, no model files required — good for CI and smoke).
//
// The daemon refuses to start if Voice.Enabled but Bind is empty — the
// bind is the one operational config the surface can't default (127.0.0.1
// is too relaxed for production, a wg-tunnel address is the user's);
// surfacing the gap explicitly beats an idle listener the user thinks is
// wired but isn't reachable.
type VoiceConfig struct {
	Enabled  bool            `json:"enabled,omitempty"`
	Bind     string          `json:"bind,omitempty"`
	Lang     string          `json:"lang,omitempty"`
	Stt      *WorkerConfig   `json:"stt,omitempty"`
	Tts      *TtsConfig      `json:"tts,omitempty"`
	Embedder *EmbedderConfig `json:"embedder,omitempty"`

	// RouterThreshold — the minimum confidence score for the intent classifier
	// (stage 3 gate). Below this → clarify, don't guess. 0 ⇒
	// DefaultRouterThreshold (0.55), tuned for the ONNX embedder; the
	// HashEmbedder floor scores lexically and may need a lower value.
	//
	// There is no way to ask for "permissive, never clarify" through this
	// field: normaliseVoice replaces anything ≤ 0 with the default, so a
	// written 0 is the default and a written negative is too.
	RouterThreshold float64 `json:"router_threshold,omitempty"`

	// LLMRouter — route with the resident model instead of the embedding
	// classifier. On by default since Vikunja #320.
	//
	// Measured on the held-out fixture (docs/evals/2026-07-31-routing.md): 63.2% of
	// intents right against the classifier's 50.0%, and no route errors. It
	// costs about 1s per turn instead of 30ms.
	//
	// It is safe to leave on. The model can refuse — it answers "unknown" when
	// it cannot route, and the turn drops to the classifier and its clarify
	// gate. Any LLM error does the same, so a turn never breaks on the model.
	// Slot extraction runs on LLM decisions too, so acts get their Fn and
	// reminders their Time.
	//
	// Set it false to go back to the classifier, e.g. on a box with no
	// llama-server or when 1s a turn is too slow.
	//
	// It is a pointer so that "missing from the file" and "explicitly false"
	// are different things: missing means on, false means off. Read it with
	// UseLLMRouter(), not directly.
	LLMRouter *bool `json:"llm_router,omitempty"`

	// QueryMinScore — the note-recall confidence gate. Top cosine below this
	// ⇒ "I don't know" instead of a guess. Tuned for the corrected ONNX
	// embedder (0.80); the HashEmbedder floor scores lexically and may never clear it. 0.80
	// default if unset.
	QueryMinScore float64 `json:"query_min_score,omitempty"`

	// QueryMinMargin — the second half of the recall gate: the top hit must
	// beat the runner-up by more than this. The absolute score above cannot do
	// the job on its own, because the e5 embedder puts every cosine in one
	// narrow high band, so a made-up question scores as high as a real one.
	// The margin asks whether one note is clearly the best instead.
	// Negative ⇒ off. 0 ⇒ the default below.
	QueryMinMargin float64 `json:"query_min_margin,omitempty"`

	// ClarifyMaxAttempts — how many clarifying questions she may ask about one
	// request before she gives up and says she did not understand. Default 3.
	ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"`

	// Persona — optional prompt prefix that tunes maven's character. Prepended
	// to every LLM system prompt (nudge phrasing, note queries, general
	// knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered
	// Russian self-reference). Example: "Be formal and answer in English only."
	Persona string `json:"persona,omitempty"`

	// OwnerName / City — optional facts about the owner, added to the shared
	// context block (internal/persona). Empty is fine: the block still states
	// who he is grammatically (a man, addressed as "ты") and the current time.
	// Nothing about correct behaviour may depend on these being filled in.
	OwnerName string `json:"owner_name,omitempty"`
	City      string `json:"city,omitempty"`

	// Weather — the weather provider config. nil ⇒ the daemon wires
	// the stub provider (returns ErrNotConfigured — "погода не настроена").
	// Set provider to "open-meteo" to use the keyless Open-Meteo API.
	Weather *WeatherConfig `json:"weather,omitempty"`

	// Tools — the enabled act allowlist. Each is a spoken verb → argv the
	// executor runs (args from the utterance appended). Editing this set is the
	// human-only "enable" act (per spec); maven can't add to it from a request.
	// Empty ⇒ every act is refused (nothing enabled).
	Tools []ToolConfig `json:"tools,omitempty"`

	// ToolTimeout bounds each tool invocation. Zero ⇒ executor default (30s).
	ToolTimeout Duration `json:"tool_timeout,omitempty"`
}
