// Package config holds Cerberus' configuration: the types every other package
// reads, their defaults, and load/save of ~/.config/cerberus/config.json.
//
// FROZEN CONTRACT: other packages are written against these types. Add fields
// only; never rename or remove one without updating every user.
package config

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"time"
)

// Config is the whole configuration file.
type Config struct {
	Server       ServerConfig     `json:"server"`
	GPU          GPUConfig        `json:"gpu"`
	Engines      EnginesConfig    `json:"engines"`
	Models       []ModelConfig    `json:"models"`    // explicit local models (llamacpp) and overrides for discovered ones
	Providers    []ProviderConfig `json:"providers"` // cloud / remote OpenAI-compatible APIs (API keys, never subscriptions)
	Agents       []AgentProfile   `json:"agents"`    // merged over DefaultAgents() by Name
	DefaultAgent string           `json:"default_agent"`
	Agent        AgentConfig      `json:"agent"` // the built-in agent
	Context      ContextConfig    `json:"context"`
	Repos        []RepoConfig     `json:"repos"`
	RepoRoots    []string         `json:"repo_roots"` // folders whose direct git children are offered as repos
	Sessions     SessionsConfig   `json:"sessions"`
	Guard        GuardConfig      `json:"guard"`
	Notify       NotifyConfig     `json:"notify"`
	Git          GitConfig        `json:"git"`

	// path is where this config was loaded from (not serialised).
	path string
}

type ServerConfig struct {
	Listen      string   `json:"listen"`        // default "127.0.0.1:7340"
	ExtraListen []string `json:"extra_listen"`  // e.g. "100.x.y.z:7340" (Tailscale)
	Token       string   `json:"token"`         // generated on first run
	LocalNoAuth bool     `json:"local_no_auth"` // /v1 from loopback needs no token (desktop clients)
	PublicURL   string   `json:"public_url"`    // used in notifications/links, e.g. "http://desk.tailnet.ts.net:7340"
}

type GPUConfig struct {
	Backend          string `json:"backend"`            // "auto" | "nvidia" | "amd" | "none"
	ActiveIntervalMs int    `json:"active_interval_ms"` // default 1000
	IdleIntervalMs   int    `json:"idle_interval_ms"`   // default 15000
	NvidiaSmi        string `json:"nvidia_smi"`         // default "nvidia-smi"
}

type EnginesConfig struct {
	DefaultModel    string         `json:"default_model"`
	IdleTTLSec      int            `json:"idle_ttl_sec"`     // default 600
	LoadTimeoutSec  int            `json:"load_timeout_sec"` // default 180
	AllowOverCommit bool           `json:"allow_overcommit"`
	LlamaCpp        LlamaCppConfig `json:"llamacpp"`
	LMStudio        LMStudioConfig `json:"lmstudio"`
	Ollama          OllamaConfig   `json:"ollama"`
}

type LlamaCppConfig struct {
	Enabled   bool     `json:"enabled"`    // default true (no-op when Binary missing)
	Binary    string   `json:"binary"`     // default "llama-server"
	ModelDirs []string `json:"model_dirs"` // scanned for *.gguf
	Ctx       int      `json:"ctx"`        // default 32768
	Parallel  int      `json:"parallel"`   // default 4 (slots; concurrent agents share the model)
	Args      []string `json:"args"`       // extra args for every model
	PortBase  int      `json:"port_base"`  // default 39200
}

type LMStudioConfig struct {
	Enabled bool   `json:"enabled"`  // default true (no-op when lms missing)
	Binary  string `json:"binary"`   // default "lms" (also tries ~/.lmstudio/bin/lms)
	BaseURL string `json:"base_url"` // default "http://127.0.0.1:1234/v1"
	Ctx     int    `json:"ctx"`      // default 32768
}

type OllamaConfig struct {
	Enabled bool   `json:"enabled"` // default true (no-op when unreachable)
	URL     string `json:"url"`     // default "http://127.0.0.1:11434"
}

// ModelConfig declares a local model or overrides a discovered one (matched by ID).
type ModelConfig struct {
	ID       string   `json:"id"`
	Backend  string   `json:"backend"` // "llamacpp" | "lmstudio" | "ollama"
	Path     string   `json:"path"`    // llamacpp: .gguf path
	Name     string   `json:"name"`    // backend-native name if different from ID
	Ctx      int      `json:"ctx"`
	Parallel int      `json:"parallel"`
	TTLSec   int      `json:"ttl_sec"`
	SizeMiB  int      `json:"size_mib"` // VRAM estimate override
	Args     []string `json:"args"`
	Vision   bool     `json:"vision"`
	Draft    string   `json:"draft"` // llamacpp: draft model path for speculative decoding (-md)
}

type ProviderConfig struct {
	Name    string            `json:"name"`     // id prefix, e.g. "anthropic", "openai", "openrouter"
	BaseURL string            `json:"base_url"` // ends in /v1 (or provider's OpenAI-compatible root)
	KeyEnv  string            `json:"key_env"`  // env var holding the key; else secrets.json[name]
	Models  []string          `json:"models"`   // empty = discover via GET /models
	Headers map[string]string `json:"headers"`
	Enabled bool              `json:"enabled"`
}

// AgentProfile is how to start one kind of coding agent in a PTY.
// Command placeholders: {task} {task_file} {model} {base_url} {cwd} {session} {self} ({self} = path of the cerberus binary).
type AgentProfile struct {
	Name        string            `json:"name"`
	Command     []string          `json:"command"`
	Env         map[string]string `json:"env"`
	NeedsModel  bool              `json:"needs_model"` // shows the model picker
	Interactive bool              `json:"interactive"` // stays open after finishing (idle → waiting)
	Description string            `json:"description"`
}

type AgentConfig struct {
	Approve         string   `json:"approve"`   // "ask" (default) | "auto"
	MaxSteps        int      `json:"max_steps"` // default 60
	DenyPaths       []string `json:"deny_paths"`
	CommandTimeoutS int      `json:"command_timeout_s"` // default 120
}

type ContextConfig struct {
	Files           []string `json:"files"`              // extra context for the built-in agent (e.g. memory index)
	MaxBytesPerFile int      `json:"max_bytes_per_file"` // default 16384
}

type RepoConfig struct {
	Name    string         `json:"name"`
	Path    string         `json:"path"`
	Checks  []string       `json:"checks"`
	Preview *PreviewConfig `json:"preview,omitempty"`
	Push    bool           `json:"push"` // default for "push" on accept
}

type PreviewConfig struct {
	Dir string `json:"dir"` // relative to the worktree; "." = root
}

type SessionsConfig struct {
	MaxAttempts       int `json:"max_attempts"`        // default 2
	ChecksTimeoutSec  int `json:"checks_timeout_sec"`  // default 600
	IdleWaitSec       int `json:"idle_wait_sec"`       // default 20
	ScrollbackMiB     int `json:"scrollback_mib"`      // default 8
	ScrollbackTailKiB int `json:"scrollback_tail_kib"` // default 256
	DefaultCols       int `json:"default_cols"`        // default 120
	DefaultRows       int `json:"default_rows"`        // default 36
}

type GuardConfig struct {
	MaxSessions      int    `json:"max_sessions"`       // default 3
	MinFreeMiB       int    `json:"min_free_mib"`       // default 4096
	SystemdSlice     string `json:"systemd_slice"`      // default "agents.slice" (used only if systemd-run exists)
	SessionMemoryMax string `json:"session_memory_max"` // default "6G"
}

type NotifyConfig struct {
	Command  []string `json:"command"` // argv with {title} {body} {urgency}; default Atrium notify if present
	NtfyURL  string   `json:"ntfy_url"`
	Disabled bool     `json:"disabled"`
}

type GitConfig struct {
	Name  string `json:"name"` // commit identity on accept; empty = repo git config
	Email string `json:"email"`
	// Accept never adds attribution trailers. There is deliberately no option for it.
}

// Defaults returns a config with every default filled in (no token).
func Defaults() *Config {
	home, _ := os.UserHomeDir()
	c := &Config{
		Server: ServerConfig{Listen: "127.0.0.1:7340", LocalNoAuth: true},
		GPU:    GPUConfig{Backend: "auto", ActiveIntervalMs: 1000, IdleIntervalMs: 15000, NvidiaSmi: "nvidia-smi"},
		Engines: EnginesConfig{
			IdleTTLSec: 600, LoadTimeoutSec: 180,
			LlamaCpp: LlamaCppConfig{Enabled: true, Binary: "llama-server", Ctx: 32768, Parallel: 4, PortBase: 39200,
				ModelDirs: []string{filepath.Join(home, "models")}},
			LMStudio: LMStudioConfig{Enabled: true, Binary: "lms", BaseURL: "http://127.0.0.1:1234/v1", Ctx: 32768},
			Ollama:   OllamaConfig{Enabled: true, URL: "http://127.0.0.1:11434"},
		},
		DefaultAgent: "cerberus",
		Agent: AgentConfig{Approve: "ask", MaxSteps: 60, CommandTimeoutS: 120,
			DenyPaths: []string{"**/Carino-Systems-Business/**", "**/.ssh/**", "**/.gnupg/**", "**/secrets.json"}},
		Context:   ContextConfig{MaxBytesPerFile: 16384},
		RepoRoots: []string{filepath.Join(home, "Github")},
		Sessions: SessionsConfig{MaxAttempts: 2, ChecksTimeoutSec: 600, IdleWaitSec: 20, ScrollbackMiB: 8,
			ScrollbackTailKiB: 256, DefaultCols: 120, DefaultRows: 36},
		Guard: GuardConfig{MaxSessions: 3, MinFreeMiB: 4096, SystemdSlice: "agents.slice", SessionMemoryMax: "6G"},
	}
	if p := filepath.Join(home, ".config", "hypr", "scripts", "notify"); fileExists(p) {
		c.Notify.Command = []string{p, "{title}", "{body}"}
	}
	return c
}

// DefaultAgents are the built-in profiles; config Agents override by Name.
func DefaultAgents() []AgentProfile {
	return []AgentProfile{
		{Name: "cerberus", Command: []string{"{self}", "agent", "--base", "{base_url}", "--model", "{model}", "--task-file", "{task_file}", "--cwd", "{cwd}", "--session", "{session}"},
			NeedsModel: true, Description: "Built-in agent: any local or API model through the gateway"},
		{Name: "claude", Command: []string{"claude", "{task}"}, Interactive: true,
			Description: "Claude Code, interactive, on your own subscription"},
		{Name: "claude-headless", Command: []string{"claude", "-p", "{task}", "--permission-mode", "acceptEdits"},
			Description: "Claude Code, non-interactive (edits accepted, commands per your settings)"},
		{Name: "opencode", Command: []string{"opencode", "run", "{task}"}, NeedsModel: true,
			Description: "OpenCode (point it at the gateway via OPENAI_BASE_URL)"},
		{Name: "aider", Command: []string{"aider", "--model", "openai/{model}", "--message", "{task}", "--yes-always"}, NeedsModel: true,
			Description: "Aider through the gateway"},
		{Name: "shell", Command: []string{"bash", "-l"}, Interactive: true, Description: "A plain shell in the worktree"},
	}
}

// AgentProfiles merges DefaultAgents with c.Agents (config wins by Name, new names appended).
func (c *Config) AgentProfiles() []AgentProfile {
	out := DefaultAgents()
	for _, a := range c.Agents {
		replaced := false
		for i := range out {
			if out[i].Name == a.Name {
				out[i] = a
				replaced = true
			}
		}
		if !replaced {
			out = append(out, a)
		}
	}
	return out
}

// Agent finds a profile by name.
func (c *Config) AgentProfile(name string) (AgentProfile, bool) {
	for _, a := range c.AgentProfiles() {
		if a.Name == name {
			return a, true
		}
	}
	return AgentProfile{}, false
}

// IdleTTL for a model config (0 = engine default).
func (c *Config) IdleTTL(m *ModelConfig) time.Duration {
	if m != nil && m.TTLSec > 0 {
		return time.Duration(m.TTLSec) * time.Second
	}
	return time.Duration(c.Engines.IdleTTLSec) * time.Second
}

// Path returns where the config lives.
func (c *Config) Path() string { return c.path }

// DefaultPath is os.UserConfigDir()/cerberus/config.json.
func DefaultPath() (string, error) {
	d, err := os.UserConfigDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(d, "cerberus", "config.json"), nil
}

// Dir is the folder holding config.json and secrets.json.
func (c *Config) Dir() string { return filepath.Dir(c.path) }

// Load reads path (DefaultPath if empty) over Defaults(). A missing file is
// created with defaults and a fresh token (mode 0600). A config without a
// token gets one and is saved.
func Load(path string) (*Config, error) {
	if path == "" {
		p, err := DefaultPath()
		if err != nil {
			return nil, err
		}
		path = p
	}
	c := Defaults()
	c.path = path
	raw, err := os.ReadFile(path)
	switch {
	case errors.Is(err, fs.ErrNotExist):
		// first run
	case err != nil:
		return nil, err
	default:
		if err := json.Unmarshal(raw, c); err != nil {
			return nil, fmt.Errorf("config %s: %w", path, err)
		}
		c.path = path
	}
	if c.Server.Token == "" || errors.Is(err, fs.ErrNotExist) {
		if c.Server.Token == "" {
			c.Server.Token = NewToken()
		}
		if err := c.Save(); err != nil {
			return nil, err
		}
	}
	return c, nil
}

// Save writes the config atomically with mode 0600.
func (c *Config) Save() error {
	if c.path == "" {
		p, err := DefaultPath()
		if err != nil {
			return err
		}
		c.path = p
	}
	if err := os.MkdirAll(filepath.Dir(c.path), 0o700); err != nil {
		return err
	}
	raw, err := json.MarshalIndent(c, "", "  ")
	if err != nil {
		return err
	}
	tmp := c.path + ".tmp"
	if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil {
		return err
	}
	return os.Rename(tmp, c.path)
}

// SetPath sets where Save writes (tests).
func (c *Config) SetPath(p string) { c.path = p }

// Secret returns secrets.json[name] from the config dir ("" if absent).
func (c *Config) Secret(name string) string {
	raw, err := os.ReadFile(filepath.Join(c.Dir(), "secrets.json"))
	if err != nil {
		return ""
	}
	m := map[string]string{}
	if json.Unmarshal(raw, &m) != nil {
		return ""
	}
	return m[name]
}

// NewToken returns 32 random bytes as hex.
func NewToken() string {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}

func fileExists(p string) bool {
	st, err := os.Stat(p)
	return err == nil && !st.IsDir()
}
