package config

import (
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/kami/maven/internal/vision"
)

// The senses: seeing, hearing, and knowing who spoke. All three are off unless
// someone typed a path on purpose, and all three depend on the media block —
// nothing in this repo holds an image or a recording only in memory.

// MediaConfig — the on-disk blob store for images and captured audio
// (internal/media). It is shared by all three senses: vision intake, meeting
// capture, and speaker enrolment samples all write here.
//
// Absent ⇒ off, and off means Maven cannot accept an image or start a recording
// at all. That default is deliberate: a capability that keeps photos and audio of
// people on disk should require someone to have typed a path.
type MediaConfig struct {
	// Dir — the blob store root, created 0700. Relative paths resolve against
	// StateDir. Required; an empty dir means the store is not wired.
	Dir string `json:"dir,omitempty"`

	// Retention — how long a blob is kept before the tick prunes it. 0 ⇒
	// media.DefaultRetention (7 days). This is the knob that stops recordings
	// of people accumulating; raising it past a few weeks should need a reason.
	Retention Duration `json:"retention,omitempty"`

	// MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB).
	MaxBytes int64 `json:"max_bytes,omitempty"`

	// MaxTotalBytes — whole-store cap. 0 ⇒ media.DefaultMaxTotalBytes (4 GiB).
	// The per-blob cap bounds one call; this one bounds the sum of them, which
	// is what actually decides whether the disk mavend's database lives on can
	// be filled from outside.
	MaxTotalBytes int64 `json:"max_total_bytes,omitempty"`
}

// StoreDir reports the configured blob directory, or "" when media is not
// wired. Safe on a nil receiver.
func (m *MediaConfig) StoreDir() string {
	if m == nil {
		return ""
	}
	return strings.TrimSpace(m.Dir)
}

// validateMedia fails a media dir that cannot be created here rather than at
// wiring time. A capability silently not existing is the hardest kind of
// misconfiguration to notice.
func (c *Config) validateMedia() error {
	if c.Media == nil {
		return nil
	}
	if c.Media.StoreDir() == "" {
		return errors.New("media.dir is required when a media block is present")
	}
	if c.Media.MaxBytes < 0 || c.Media.MaxTotalBytes < 0 {
		return errors.New("media: max_bytes and max_total_bytes cannot be negative")
	}
	if c.Media.MaxTotalBytes > 0 && c.Media.MaxBytes > c.Media.MaxTotalBytes {
		return fmt.Errorf("media: max_bytes %d is above max_total_bytes %d",
			c.Media.MaxBytes, c.Media.MaxTotalBytes)
	}
	return nil
}

// VisionConfig — the vision provider (internal/vision, docs/plans/07-vision.md).
//
// Absent, or enabled=false, ⇒ the daemon wires vision.Disabled and every attempt
// to look at an image answers that vision is not set up. There is no cloud
// option in this block on purpose: Endpoint must be a loopback or private
// address and internal/vision refuses anything else at startup, because
// inference stays on the box and a photo of his flat is the last thing to make
// an exception for.
type VisionConfig struct {
	// Enabled — may she look at images. Default false.
	Enabled bool `json:"enabled,omitempty"`

	// Endpoint — base URL of a llama-server running a vision model with its
	// mmproj, e.g. "http://127.0.0.1:8081". Loopback / private only.
	Endpoint string `json:"endpoint,omitempty"`

	// Model — model name sent in the request. llama-server ignores it.
	Model string `json:"model,omitempty"`

	// MaxDim — longest edge the image is scaled to before inference. 0 ⇒
	// media.DefaultMaxDim (896).
	MaxDim int `json:"max_dim,omitempty"`

	// MaxTokens — cap on the description. 0 ⇒ vision.DefaultMaxTokens (300).
	MaxTokens int `json:"max_tokens,omitempty"`

	// Timeout — per-description budget. 0 ⇒ vision.DefaultTimeout (90s). A small
	// VLM on an iGPU is slow; a tight timeout here just means no answer ever.
	Timeout Duration `json:"timeout,omitempty"`

	// Prompt — the default question when he only sent a picture. Empty ⇒
	// vision.DefaultPrompt (Russian, "опиши что на изображении").
	Prompt string `json:"prompt,omitempty"`
}

// LooksAtImages reports whether vision is configured well enough to try. Safe on
// a nil receiver, and false without an endpoint — enabled with nothing to talk
// to is a misconfiguration, not a capability.
func (v *VisionConfig) LooksAtImages() bool {
	return v != nil && v.Enabled && strings.TrimSpace(v.Endpoint) != ""
}

// validateVision fails an endpoint that is a typo, or a vision block with
// nowhere to keep the bytes, at startup.
func (c *Config) validateVision() error {
	if c.Vision == nil || !c.Vision.Enabled {
		return nil
	}
	if strings.TrimSpace(c.Vision.Endpoint) == "" {
		return errors.New("vision.enabled set but vision.endpoint is empty")
	}
	if err := vision.ValidateEndpoint(c.Vision.Endpoint); err != nil {
		return err
	}
	if c.Media.StoreDir() == "" {
		return errors.New("vision.enabled set but there is no media block to keep the bytes in")
	}
	return nil
}

// CaptureConfig — the meeting recorder (internal/capture,
// docs/plans/08-hearing.md).
//
// Absent, or enabled=false, ⇒ the recorder is not wired and the capture methods
// return "unknown method", so no client can start a recording however it asks.
// A media block is required too: audio is never held only in memory.
//
// There is deliberately no "auto", no keyword trigger and no duration default
// long enough to be forgotten about. Recording other people is an explicit act
// with a start, a stop, and a cap.
type CaptureConfig struct {
	// Enabled — may she record a meeting when asked. Default false.
	Enabled bool `json:"enabled,omitempty"`

	// MaxMinutes — hard cap on one session; it stops itself there. 0 ⇒
	// capture.DefaultMaxDuration (120 minutes).
	MaxMinutes int `json:"max_minutes,omitempty"`

	// STTWindow — audio handed to whisper per call. 0 ⇒
	// capture.DefaultSTTWindow (5m). Larger windows transcribe slightly better
	// and block the STT worker for longer.
	STTWindow Duration `json:"stt_window,omitempty"`

	// ChunkRunes — transcript runes per summarisation prompt. 0 ⇒
	// capture.DefaultChunkRunes (3000), sized for the resident model's n_ctx of
	// 4096. Raise this only if the resident model's context grows.
	ChunkRunes int `json:"chunk_runes,omitempty"`

	// MaxChunks — how many windows one meeting may be summarised in before the
	// transcript is truncated and the summary says so. 0 ⇒
	// capture.DefaultMaxChunks (40).
	MaxChunks int `json:"max_chunks,omitempty"`

	// SaveTranscript — write the full transcript as a note alongside the
	// summary. Default false, and the cost is not disk: a note is embedded and
	// becomes recall corpus, so every later question can surface verbatim words
	// other people said in a room. That is the reason it takes a deliberate yes.
	// The audio blob is pruned by media.retention either way; the notes are not.
	//
	// A meeting with no summary writes its transcript regardless. The choice
	// here is transcript IN ADDITION to a summary, not whether the meeting is
	// remembered at all.
	SaveTranscript bool `json:"save_transcript,omitempty"`
}

// Records reports whether the recorder should be wired. Safe on a nil receiver.
func (c *CaptureConfig) Records() bool {
	return c != nil && c.Enabled
}

// MaxDuration is the configured session cap as a duration, or 0 for the
// package default. Safe on a nil receiver.
func (c *CaptureConfig) MaxDuration() time.Duration {
	if c == nil || c.MaxMinutes <= 0 {
		return 0
	}
	return time.Duration(c.MaxMinutes) * time.Minute
}

// validateCapture refuses a recorder with nowhere to keep the audio.
func (c *Config) validateCapture() error {
	if c.Capture.Records() && c.Media.StoreDir() == "" {
		return errors.New("capture.enabled set but there is no media block to keep the audio in")
	}
	return nil
}

// SpeakerConfig — voice identification (internal/speaker,
// docs/plans/10-speaker-recognition.md).
//
// Absent, or enabled=false, ⇒ no voiceprint is computed for any turn, the
// enrolment methods do not exist, and nobody can be enrolled. A voiceprint is
// biometric data about a person, so this one is off until someone typed a model
// path on purpose.
//
// It cannot currently be turned on: there is no speaker-embedding model on this
// box. See the plan document for what to download.
type SpeakerConfig struct {
	// Enabled — may she work out who is speaking. Default false.
	Enabled bool `json:"enabled,omitempty"`

	// ModelPath — an ECAPA-TDNN (or equivalent) speaker-embedding ONNX model.
	// Required; without it the recognizer runs disabled and says so once.
	ModelPath string `json:"model_path,omitempty"`

	// LibPath — onnxruntime shared library, as for the text embedder. Empty ⇒
	// the same default the embedder block uses.
	//
	// Nothing reads it yet: cmd/mavend's newSpeakerEmbedder discards the whole
	// block, because there is no speaker model on this box to load. It stays
	// declared so the block a reader writes matches the plan document.
	LibPath string `json:"lib_path,omitempty"`

	// Threshold — cosine similarity a match must beat. 0 ⇒
	// speaker.DefaultThreshold (0.7). Lower it and she starts calling guests by
	// his name, which is the expensive direction of this error.
	Threshold float64 `json:"threshold,omitempty"`

	// MinSeconds — least speech an identification will look at. 0 ⇒
	// speaker.DefaultMinSeconds (2s).
	MinSeconds float64 `json:"min_seconds,omitempty"`
}

// Recognizes reports whether voice identification should be wired. Safe on a
// nil receiver, and false without a model path — enabled with nothing to embed
// with is a misconfiguration, not a capability.
func (s *SpeakerConfig) Recognizes() bool {
	return s != nil && s.Enabled && strings.TrimSpace(s.ModelPath) != ""
}
