// Package config is maven's daemon configuration.
//
// The daemon reads a single JSON file at startup (path from the -config flag,
// default ~/.config/maven/mavend.json). Everything a module needs is wired
// from this file: the store path, the unix socket path, the tick cadence,
// and per-sink configs (ntfy/telegram). Credentials live in the file (or a
// systemd credential that the file points at) — never in the binary.
//
// This package is pure data + a loader. It imports the sink config structs
// so the daemon wires each `Sink` from a single, typed config tree without
// re-declaring their shapes (the sink constructors own validation).
package config

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"time"

	"github.com/kami/maven/internal/delivery/ntfysink"
	"github.com/kami/maven/internal/delivery/telegramsink"
	"github.com/kami/maven/internal/update"
)

// Config — the daemon's whole config tree. Loaded once at startup.
//
// Fields with omitempty are optional: a missing sink config = that channel
// not wired (the dispatcher's nil-sink path skips it silently, the same as a
// deliberately-unwired channel at scaffold time).
type Config struct {
	// DBPath — sqlite database path. Default applied by Load if empty.
	// When encryption is configured, the file at this path is ciphertext
	// (AES-256-GCM); the daemon works on a tmpfs plaintext copy.
	DBPath string `json:"db_path"`

	// DBKeyB64 — base64 (std encoding) of a raw 32-byte AES-256 key. Empty ⇒
	// the store is plaintext (dev/CI). Prefer DBKeyEnv over baking the key
	// into the config file. Exactly one of DBKeyB64/DBKeyEnv should be set.
	//
	// ponytail: raw key, no KDF — stdlib has no argon2/scrypt and x/crypto
	// isn't a dep. This is also the seam the L3 passkey cold-start key plugs
	// into later: the passkey op produces the 32 bytes and calls
	// store.OpenEncrypted directly, bypassing config.
	DBKeyB64 string `json:"db_key_b64,omitempty"`

	// DBKeyEnv — name of an env var holding the base64 32-byte key. Takes
	// precedence over DBKeyB64. Lets systemd credentials / secrets managers
	// inject the key without it touching the config file.
	DBKeyEnv string `json:"db_key_env,omitempty"`

	// DBTmpfs — plaintext working-copy path (RAM-backed). Empty ⇒ a stable
	// per-db path under /dev/shm. Only used when encryption is configured.
	DBTmpfs string `json:"db_tmpfs,omitempty"`

	// SocketPath — the unix socket the IPC server listens on. Modules
	// connect here; the dir is created 0700, the socket chmod'd 0600 by
	// ipc.Listen. Default applied by Load if empty.
	SocketPath string `json:"socket_path"`

	// StateDir — base dir for db + socket if their paths aren't absolute.
	// Default applied by Load if empty (XDG-style: ~/.local/share/maven for
	// the db, /run/user/$UID/maven for the socket).
	StateDir string `json:"state_dir,omitempty"`

	// TickInterval — the proactive loop cadence. Default 60s. The loop is
	// "dumb + deterministic": most ticks evaluate a few predicates and die
	// for free; raising this saves nothing worth losing responsiveness over.
	TickInterval Duration `json:"tick_interval,omitempty"`

	// RepeatInterval — how often sev4 telegram sends re-fire until acked.
	// Default 5m. A disk-fire alarm that repeats every tick (60s) is spam;
	// one that repeats never is silent. The default tilts toward "loud."
	RepeatInterval Duration `json:"repeat_interval,omitempty"`

	// AutotuneInterval — how often the feedback auto-tuner runs: reads
	// store.RecentOutcomes for each rule, calls loop.TuneCooldown, writes the
	// tuned cooldown back as a `facts (kind=config, source=feedback)` row
	// if it changed. Default 10m — slow enough to be cheap + not write every
	// tick (append-only facts churn), fast enough that a weird-afternoon
	// pattern shows up inside a day. 0 ⇒ autotune disabled (the gatherer
	// falls back to the rule's static Base, matching pre-autotune behavior).
	AutotuneInterval Duration `json:"autotune_interval,omitempty"`

	// FactEnrichmentInterval — how often the fact-entity enrichment worker
	// polls for facts with resolution_state='pending' and resolves their
	// subject against Nexus. Default 30s. Only runs when Nexus is configured;
	// no-ops (harmlessly) otherwise.
	FactEnrichmentInterval Duration `json:"fact_enrichment_interval,omitempty"`

	// Ntfy — the ntfy push sink config. nil ⇒ ntfy channel not wired.
	// sev3 (ops soft) away + sev4 (ops hard) present + reminders away all
	// route here; not wiring ntfy means those routes drop silently.
	Ntfy *ntfysink.Config `json:"ntfy,omitempty"`

	// Telegram — the telegram push sink config. nil ⇒ telegram channel
	// not wired. sev4 away routes here with repeat-til-ack; not wiring
	// telegram means sev4-away alarms silently drop (a disk-fire alarm at
	// 2am that no one sees — wire it).
	Telegram *telegramsink.Config `json:"telegram,omitempty"`

	// Phraser — the LLM-backed phraser config. nil ⇒ the daemon uses the
	// template-based Stub (deterministic, no model required — good for CI).
	// When configured, the daemon spawns llama-server as a subprocess and
	// calls its /v1/chat/completions endpoint to phrase nudges and reminders.
	Phraser *PhraserConfig `json:"phraser,omitempty"`

	// Update — how THIS box deploys a new build of Maven (Vikunja #249). nil ⇒
	// the update capability does not exist, which is the state to leave it in
	// unless the operator has read internal/update's package comment.
	//
	// mavend never acts on this block: it constructs no Updater and cannot
	// update itself. Validate below is the one thing the daemon does with it, so
	// a broken update config is caught at startup instead of on the night it is
	// needed. That validation is also why internal/update is linked into mavend
	// at all — linked, with no caller, which is the property that matters. The
	// block lives here because cmd/mavupdate — a CLI the owner runs on the host,
	// the only trigger there is — reads the same config file to find the socket
	// it health-checks.
	Update *update.Config `json:"update,omitempty"`

	// Voice — the client↔core surface + the stt/tts modules the daemon
	// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
	// down, the dispatcher's Voice slot stays nil (the routing table's
	// ChannelVoice selections drop silently — same as pre-voice behaviour).
	// To enable: voice.enabled = true AND voice.bind = an address inside
	// the wg tunnel; the daemon binds the TCP listener there.
	Voice *VoiceConfig `json:"voice,omitempty"`

	// QuietHours — time-window schedule for quiet hours. When set, the
	// loop's gatherer sets `quiet = true` in the State during the window,
	// suppressing care nudges (sev1-2). The user can also toggle quiet
	// hours by voice ("тихий режим") — that writes a `quiet_hours` config
	// fact independently; both the schedule AND the toggle activate quiet.
	// nil ⇒ quiet hours only activate via the voice toggle.
	QuietHours *QuietHoursConfig `json:"quiet_hours,omitempty"`

	// DisabledRules — nudge rules that are not wired at all, by name
	// ("service_down", "netdata_critical", "water", "meal", "break").
	//
	// Rules are code, not config (see loop.DefaultRules), and that stays true:
	// this only subtracts. It exists because a rule can be right in principle
	// and useless in practice. service_down was the case that forced it: it
	// could not name the service it was nudging about, so being told "a service
	// on homesrv is down" every fifteen minutes was noise with no action
	// attached. That is fixed — one fact per kuma monitor — and the rule ships
	// enabled again. The escape hatch stays.
	//
	// A disabled rule is never gathered for, never evaluated, and never
	// delivered on any channel. Unknown names are ignored, so removing a rule
	// from the code does not break a config that still lists it.
	// Empty ⇒ every rule runs, which is the default.
	DisabledRules []string `json:"disabled_rules,omitempty"`

	// Digest — notification batching / digest mode. nil ⇒ digest disabled
	// (every nudge is sent as it fires — legacy behaviour).
	Digest *DigestConfig `json:"digest,omitempty"`

	// Routines — scheduled behaviors maven performs on a cron schedule (a
	// morning briefing, an evening wind-down), independent of any request or
	// care predicate. Each fires its Body through the dispatcher on its Cron
	// schedule. Empty ⇒ no routines. See internal/routine for the class
	// distinction from reminders (user-stated) and care rules (world-state).
	Routines []RoutineConfig `json:"routines,omitempty"`

	// MorningRoutines — daily checklists (medicine, water, pets, ...) checked
	// once near the end of a time window instead of firing one reminder per
	// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
	MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`

	// PatternProposals — whether a routine the digestion tick inferred on its
	// own may be announced, and how often. nil / absent ⇒ silent detection
	// only: proposals are written for /routines and never announced. See
	// PatternProposalConfig.
	PatternProposals *PatternProposalConfig `json:"pattern_proposals,omitempty"`

	// MemoryEval — background memory evaluation (internal/memeval). nil /
	// absent ⇒ no evaluation loop at all. See MemoryEvalConfig.
	MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"`

	// Email — mail ingestion (Vikunja #246). nil / absent ⇒ core refuses
	// ipc.MethodIngestMail outright, so a mail reader cannot make Maven read a
	// mailbox by merely existing. See EmailConfig; the IMAP host and credential
	// live in the reader (cmd/mavmaild), never here.
	Email *EmailConfig `json:"email,omitempty"`

	// IntakeJournal — how many entries the unified intake journal keeps
	// (Vikunja #283): one envelope per thing that arrived, whatever direction it
	// came from. Absent ⇒ DefaultIntakeJournal. A NEGATIVE value turns the
	// journal off entirely, and then there is no decorator on the intake path at
	// all.
	//
	// Not gated behind an "off unless configured" block like feeds or telegram,
	// and the distinction is the one CLAUDE.md draws: that rule exists for
	// capabilities that reach OUT — a fetch, a send, a third party. This reaches
	// nowhere. It is a bounded in-memory log of writes core already performed,
	// it is read only by /events and the simulator, and nothing Maven says
	// depends on it.
	IntakeJournal int `json:"intake_journal,omitempty"`

	// Feeds — RSS/Atom feed reading (Vikunja #258). nil / absent ⇒ no feed is
	// ever fetched: reading the outside world is off unless configured, like
	// the weather and telegram. See FeedsConfig.
	Feeds *FeedsConfig `json:"feeds,omitempty"`

	// Crawl — reading a web page (Vikunja #259). nil / absent ⇒ Maven never
	// fetches a page: not on request, not on a schedule. See CrawlConfig.
	Crawl *CrawlConfig `json:"crawl,omitempty"`

	// Kiwix — the offline ZIM reader (Vikunja #122 neighbourhood). nil / absent
	// / url empty ⇒ the query chain has no ZIM source. See KiwixConfig.
	Kiwix *KiwixConfig `json:"kiwix,omitempty"`

	// Search — the SearXNG metasearch instance. nil / absent / url empty ⇒ the
	// query chain has no web-search source and Kiwix is the only encyclopedia.
	// See SearchConfig.
	Search *SearchConfig `json:"search,omitempty"`

	// Workstation — the big model on the owner's desktop, preferred over the
	// resident one when its GPU is free. nil / absent / url empty ⇒ homesrv
	// behaves exactly as it does today. See WorkstationConfig.
	Workstation *WorkstationConfig `json:"workstation,omitempty"`

	// Praxis — the ecosystem attention-state service. When configured, maven
	// calls the Praxis HTTP tools API for attention listing and item lifecycle.
	// Maven never touches Praxis's database directly (ecosystem invariant: no
	// component reads another's store). nil ⇒ ecosystem integration disabled.
	Praxis *PraxisConfig `json:"praxis,omitempty"`

	// Nexus — the canonical identity service. When configured, maven resolves
	// entity references (projects, services, devices, etc.) through Nexus
	// before acting on them. nil ⇒ resolution disabled (maven uses raw text).
	Nexus *NexusConfig `json:"nexus,omitempty"`

	// Hexis — the capability execution service. When configured, maven
	// discovers and executes capabilities through Hexis for ecosystem actions.
	// nil ⇒ no capability-aware routing.
	Hexis *HexisConfig `json:"hexis,omitempty"`

	// Vision — image understanding (Vikunja #252). nil / absent ⇒ she cannot
	// look at pictures at all: the intake refuses, and no vision server is
	// contacted. See VisionConfig.
	Vision *VisionConfig `json:"vision,omitempty"`

	// Media — where images and captured audio are kept on disk, and for how
	// long. nil / absent ⇒ no blob store is wired, which is what disables both
	// vision intake and meeting capture regardless of their own blocks: nothing
	// in this repo holds a recording only in memory. See MediaConfig.
	Media *MediaConfig `json:"media,omitempty"`

	// Capture — meeting recording and summarisation (Vikunja #253). nil /
	// absent ⇒ the recorder does not exist: the start/stop methods are not
	// served at all, so nothing on this box can begin a recording. This is the
	// most invasive capability Maven has and it is the one most firmly off by
	// default. See CaptureConfig.
	Capture *CaptureConfig `json:"capture,omitempty"`

	// Speaker — voice identification (Vikunja #255). nil / absent ⇒ no
	// voiceprint is ever computed and nobody can be enrolled. Enabling it needs
	// a speaker-embedding model, which is not on this box. See SpeakerConfig.
	Speaker *SpeakerConfig `json:"speaker,omitempty"`

	// MCP — Model Context Protocol servers Maven connects OUT to (Vikunja
	// #251). nil / absent / no enabled server ⇒ no connection is made and no
	// tool is discovered, like every other capability that reaches outside the
	// box. She is a client here, never a server: nothing exposes her own
	// capabilities to an outside caller. See MCPConfig.
	MCP *MCPConfig `json:"mcp,omitempty"`

	// SmartHome — the Home Assistant instance (Vikunja #256). nil / absent /
	// disabled ⇒ Maven neither reads the house nor touches it, and no house row
	// exists in the act allowlist. See SmartHomeConfig.
	SmartHome *SmartHomeConfig `json:"smarthome,omitempty"`

	// NetScan — the LAN scanner (Vikunja #257). nil / absent / disabled ⇒
	// Maven never puts a packet on the network looking for hosts. See
	// NetScanConfig.
	NetScan *NetScanConfig `json:"netscan,omitempty"`
}

// Duration — a time.Duration that round-trips through JSON as a string
// ("60s", "5m", "1h30m"). Plain time.Duration marshals as a nanosecond int,
// which is unreadable in a config file; this wrapper uses ParseDuration.
type Duration time.Duration

func (d Duration) MarshalJSON() ([]byte, error) {
	return json.Marshal(time.Duration(d).String())
}

func (d *Duration) UnmarshalJSON(b []byte) error {
	var s string
	if err := json.Unmarshal(b, &s); err != nil {
		return err
	}
	v, err := time.ParseDuration(s)
	if err != nil {
		return fmt.Errorf("config: bad duration %q: %w", s, err)
	}
	*d = Duration(v)
	return nil
}

// The core daemon's own defaults, applied when the field is empty or zero.
// Every other block keeps its defaults in its own file, next to the struct and
// the normalise that applies them.
const (
	DefaultTickInterval     = 60 * time.Second
	DefaultRepeatInterval   = 5 * time.Minute
	DefaultAutotuneInterval = 10 * time.Minute
	// DefaultIntakeJournal — entries kept in the unified intake journal
	// (Vikunja #283). A busy day is a few hundred intake writes, so this is
	// roughly "today and yesterday" at a few hundred KB of memory.
	DefaultIntakeJournal = 512

	DefaultFactEnrichmentInterval = 30 * time.Second
)

// Load reads the JSON config at path and applies defaults. A missing file is
// an error — the daemon refuses to start without an explicit config (the
// default-less state is too permissive: empty db path, no sinks, an idle
// loop that silently does nothing, etc. — better to surface the gap than to
// run an idle daemon the user thinks is wired).
func Load(path string) (*Config, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("config: read %s: %w", path, err)
	}
	// Expand ${VAR} or $VAR patterns from environment variables. This lets
	// secrets live in env (docker-compose env_file) rather than the config
	// file committed to git.
	expanded, missing := expandEnv(string(b))
	if len(missing) > 0 {
		// Expansion happens before typed validation. A disabled block may carry
		// empty placeholders; an enabled block that needs one of these values is
		// rejected below. Log the names too so a failed deploy says which secret
		// source was absent without ever printing a value.
		log.Printf("config: %s references unset environment variables %v — expanded them to empty; enabled integrations will reject missing credentials", path, missing)
	}
	var c Config
	if err := json.Unmarshal([]byte(expanded), &c); err != nil {
		return nil, fmt.Errorf("config: parse %s: %w", path, err)
	}
	c.applyDefaults()
	if err := c.validate(); err != nil {
		return nil, fmt.Errorf("config: %s: %w", path, err)
	}
	return &c, nil
}

// expandEnv is os.ExpandEnv plus the names it could not resolve, each reported
// once and in the order the file mentions them. A variable set to the empty
// string counts as set: the operator wrote it down, so he meant it.
func expandEnv(s string) (string, []string) {
	var missing []string
	seen := map[string]bool{}
	out := os.Expand(s, func(name string) string {
		v, ok := os.LookupEnv(name)
		if !ok && !seen[name] {
			seen[name] = true
			missing = append(missing, name)
		}
		return v
	})
	return out, missing
}

func (c *Config) applyDefaults() {
	if c.IntakeJournal == 0 {
		c.IntakeJournal = DefaultIntakeJournal
	}
	if c.TickInterval == 0 {
		c.TickInterval = Duration(DefaultTickInterval)
	}
	if c.RepeatInterval == 0 {
		c.RepeatInterval = Duration(DefaultRepeatInterval)
	}
	if c.AutotuneInterval == 0 {
		c.AutotuneInterval = Duration(DefaultAutotuneInterval)
	}
	if c.FactEnrichmentInterval == 0 {
		c.FactEnrichmentInterval = Duration(DefaultFactEnrichmentInterval)
	}
	// StateDir — when set, use it as the base for both db and socket if their
	// paths are still relative (empty). If StateDir is empty, fall back to the
	// XDG-style defaults (data dir for db, runtime dir for socket).
	if c.StateDir != "" {
		if c.DBPath == "" {
			c.DBPath = filepath.Join(c.StateDir, "maven.db")
		}
		if c.SocketPath == "" {
			c.SocketPath = filepath.Join(c.StateDir, "mavend.sock")
		}
	} else {
		if c.DBPath == "" {
			c.DBPath = filepath.Join(defaultDataDir(), "maven.db")
		}
		if c.SocketPath == "" {
			c.SocketPath = filepath.Join(defaultRuntimeDir(), "mavend.sock")
		}
	}

	// Per-block defaults. Each normalise lives beside the struct it fills in,
	// and none of them reads another block, so this is a list and not an order.
	c.normaliseVoice()
	c.normaliseRoutines()
	c.normaliseDigest()
	c.normalisePatternProposals()
	c.normaliseMemoryEval()
	c.normaliseEmail()
	c.normaliseMCP()
	c.normaliseSmartHome()
	c.normaliseNetScan()
	c.normaliseFeeds()
	c.normaliseCrawl()
	c.normaliseSearch()
	c.normaliseKiwix()
	c.normaliseWorkstation()
}

func (c *Config) validate() error {
	if err := c.validatePhraser(); err != nil {
		return err
	}
	// The update block is validated here even though mavend never acts on it: a
	// half-written update config that is only noticed by cmd/mavupdate is noticed
	// at the worst possible moment, halfway through deploying a new build.
	if c.Update != nil {
		if err := c.Update.Validate(); err != nil {
			return err
		}
	}
	if err := c.validateVoice(); err != nil {
		return err
	}
	if err := c.validateRoutines(); err != nil {
		return err
	}
	if err := c.validateMCP(); err != nil {
		return err
	}
	if err := c.validateSmartHome(); err != nil {
		return err
	}
	if err := c.validateNetScan(); err != nil {
		return err
	}
	if err := c.validateMedia(); err != nil {
		return err
	}
	if err := c.validateVision(); err != nil {
		return err
	}
	if err := c.validateCapture(); err != nil {
		return err
	}
	if err := c.validateTelegram(); err != nil {
		return err
	}
	if err := c.validateNtfy(); err != nil {
		return err
	}
	if err := c.validateWorkstation(); err != nil {
		return err
	}
	return nil
}

// validateTelegram refuses an intake half that cannot read the chat it is
// pointed at. The push half accepts an @channelusername and the intake half
// does not, so a box configured with both boots clean, keeps pushing, and
// answers nothing — the failure is invisible from the chat. Same shape as
// validateNetScan: fail the config rather than the turn.
func (c *Config) validateTelegram() error {
	if c.Telegram == nil || c.Telegram.Disabled {
		return nil
	}
	if err := telegramsink.Validate(*c.Telegram); err != nil {
		return err
	}
	if c.Telegram.Intake {
		return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID)
	}
	return nil
}

func (c *Config) validateNtfy() error {
	if c.Ntfy == nil {
		return nil
	}
	return ntfysink.Validate(*c.Ntfy)
}

// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
// a plaintext store. A configured-but-invalid key is an error (fail closed,
// never silently downgrade to plaintext).
func (c *Config) DBEncryptionKey() ([]byte, error) {
	raw := c.DBKeyB64
	if c.DBKeyEnv != "" {
		raw = os.Getenv(c.DBKeyEnv)
		if raw == "" {
			return nil, fmt.Errorf("config: db_key_env %q is set but the env var is empty", c.DBKeyEnv)
		}
	}
	if raw == "" {
		return nil, nil
	}
	key, err := base64.StdEncoding.DecodeString(raw)
	if err != nil {
		return nil, fmt.Errorf("config: db key is not valid base64: %w", err)
	}
	if len(key) != 32 {
		return nil, fmt.Errorf("config: db key must decode to 32 bytes, got %d", len(key))
	}
	return key, nil
}

// DefaultWrappedKeyPath returns the conventional path for the wrapped
// encryption key blob — alongside the StateDir. This is the path checked
// automatically when --wrapped-key-file is not provided on the command line.
// The caller may always override via the flag.
func (c *Config) DefaultWrappedKeyPath() string {
	return filepath.Join(c.StateDir, "db_key.wrapped")
}

func defaultDataDir() string {
	if x := os.Getenv("XDG_DATA_HOME"); x != "" {
		return filepath.Join(x, "maven")
	}
	home, err := os.UserHomeDir()
	if err != nil || home == "" {
		return filepath.Join(os.TempDir(), "maven")
	}
	return filepath.Join(home, ".local", "share", "maven")
}

func defaultRuntimeDir() string {
	if x := os.Getenv("XDG_RUNTIME_DIR"); x != "" {
		return filepath.Join(x, "maven")
	}
	// /run/user/$UID is the typical answer; without XDG_RUNTIME_DIR, fall back
	// to the data dir (still works; just not tmpfs-clearance-on-reboot clean).
	return filepath.Join(defaultDataDir())
}
