package config

import (
	"fmt"
	"log/slog"
	"os"
	"regexp"

	"github.com/BurntSushi/toml"
)

// envBracketRe matches only ${VAR} style env references, not bare $VAR.
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)

type Config struct {
	Matrix    MatrixConfig    `toml:"matrix"`
	Posting   PostingConfig   `toml:"posting"`
	Storage   StorageConfig   `toml:"storage"`
	Web       WebConfig       `toml:"web"`
	Adventure AdventureConfig `toml:"adventure"`
	Sources   []SourceConfig  `toml:"sources"`
}

// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
// default — the endpoint 404s and no adventure channel appears until enabled.
type AdventureConfig struct {
	Enabled bool `toml:"enabled"`
	// IngestToken is the shared bearer secret gogobee presents on
	// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
	IngestToken string `toml:"ingest_token"`
	// Channel is the Matrix channel name (a key in [matrix.channels]) that
	// PRIORITY adventure beats post to live. If it isn't a configured Matrix
	// channel, adventure runs website-only (stories still appear on /adventure).
	Channel string `toml:"channel"`
	// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
	// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
	// "unset" and doesn't get silently rewritten to the default.
	DigestHour *int `toml:"digest_hour"`
}

// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
// unset case. Safe on a zero-value AdventureConfig.
func (a AdventureConfig) DigestHourOrDefault() int {
	if a.DigestHour == nil {
		return defaultDigestHour
	}
	return *a.DigestHour
}

const defaultDigestHour = 17

// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
type WebConfig struct {
	Enabled    bool        `toml:"enabled"`
	ListenAddr string      `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
	SiteTitle  string      `toml:"site_title"`  // display name in the header
	BaseURL    string      `toml:"base_url"`    // public URL (used in metadata only)
	Auth       AuthConfig  `toml:"auth"`        // optional OIDC sign-in (Authentik)
	Push       PushConfig  `toml:"push"`        // optional Web Push digests (VAPID)
	TTS        TTSConfig   `toml:"tts"`         // optional server-side neural read-aloud (Piper)
	Games      GamesConfig `toml:"games"`       // optional casino (games.parodia.dev)
	// AdminSubs is the allowlist of OIDC subjects allowed to view the
	// owner-facing source-health dashboard at /status. Empty means the page is
	// inaccessible to everyone (returns 404). Requires auth to be enabled.
	AdminSubs []string `toml:"admin_subs"`
}

// PushConfig wires the Web Push digest sender. Push is signed-in only (it keys
// off the OIDC subject) so it does nothing unless auth is also enabled. Generate
// a VAPID keypair once with `pete -genvapid` and paste the two keys here.
type PushConfig struct {
	Enabled         bool   `toml:"enabled"`
	VAPIDPublicKey  string `toml:"vapid_public_key"`
	VAPIDPrivateKey string `toml:"vapid_private_key"`
	// Subject identifies the sender to the push service; a mailto: or https: URL
	// per RFC 8292. Defaults to mailto:admin@<base_url host> is not attempted —
	// set it explicitly.
	Subject string `toml:"subject"`
	// IntervalMinutes is how often the digest sender wakes to look for new
	// stories per subscriber. Defaults to 360 (6h).
	IntervalMinutes int `toml:"interval_minutes"`
	// MinStories is the smallest number of new stories that triggers a digest
	// for a subscriber, so they aren't pinged for a single item. Defaults to 3.
	MinStories int `toml:"min_stories"`
}

// TTSConfig wires server-side neural read-aloud (Piper). When enabled,
// signed-in users get the reader's "Listen" button backed by real Piper voices
// instead of the browser's robotic Web Speech voice. Read-aloud is a signed-in
// perk, so this does nothing unless auth is also enabled.
type TTSConfig struct {
	Enabled   bool   `toml:"enabled"`
	PiperBin  string `toml:"piper_bin"`  // path to the piper executable
	VoicesDir string `toml:"voices_dir"` // directory holding <id>.onnx (+ .onnx.json) models
	// Voices lists the voices to offer, in menu order. Each id is a model
	// filename stem, so id "en_US-ryan-high" maps to <voices_dir>/en_US-ryan-high.onnx.
	// Leave empty to auto-discover every *.onnx in voices_dir.
	Voices []VoiceConfig `toml:"voices"`
	// Default is the voice id selected until the reader picks another. Empty
	// falls back to the first available voice.
	Default string `toml:"default"`
}

// VoiceConfig is one selectable Piper voice.
type VoiceConfig struct {
	ID    string `toml:"id"`    // model filename stem, e.g. "en_US-ryan-high"
	Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
}

// GamesConfig wires the casino. It is signed-in only — there is money in it —
// so it does nothing without web.auth, and it needs the Matrix server name
// because that is how a player's identity reaches gogobee's euro ledger: an
// Authentik username is the Matrix localpart, and the Matrix id is the account.
type GamesConfig struct {
	Enabled bool `toml:"enabled"`
	// Host is the public hostname the casino answers on, e.g.
	// "games.parodia.dev". Requests arriving on it are served the casino at "/";
	// everywhere else the same pages live under /games. Empty means no host
	// branching, which is the normal state of affairs in local development.
	Host string `toml:"host"`
	// MatrixServer is the server name half of a player's Matrix id
	// ("parodia.dev" -> @reala:parodia.dev). Without it, no player can be
	// identified in the economy and the casino stays shut.
	MatrixServer string `toml:"matrix_server"`
}

// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
// When enabled, signed-in users get their preferences stored server-side keyed
// by the OIDC subject; anonymous visitors keep using browser localStorage.
type AuthConfig struct {
	Enabled       bool   `toml:"enabled"`
	Issuer        string `toml:"issuer"` // e.g. https://authentik.parodia.dev/application/o/pete/
	ClientID      string `toml:"client_id"`
	ClientSecret  string `toml:"client_secret"`
	RedirectURL   string `toml:"redirect_url"`   // e.g. https://news.parodia.dev/auth/callback
	SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
	// CookieDomain widens the session cookie beyond the host that set it, so a
	// sign-in on news.parodia.dev is also a sign-in on games.parodia.dev. Set it
	// to ".parodia.dev" to share the session across every parodia.dev host —
	// which is every host, including the landing site, so it is opt-in rather
	// than the default. Empty keeps the cookie host-only.
	CookieDomain string `toml:"cookie_domain"`
}

type MatrixConfig struct {
	Homeserver  string            `toml:"homeserver"`
	UserID      string            `toml:"user_id"`
	Password    string            `toml:"password"`
	PickleKey   string            `toml:"pickle_key"`
	DisplayName string            `toml:"display_name"`
	DataDir     string            `toml:"data_dir"`
	AdminRoom   string            `toml:"admin_room"`
	Channels    map[string]string `toml:"channels"`
	// Admins is the allowlist of Matrix user IDs permitted to run in-room
	// commands like !post. Empty means commands are disabled — the channel
	// rooms are public, so an empty list must NOT mean "anyone".
	Admins []string `toml:"admins"`
}

type PostingConfig struct {
	// Enabled is the master switch for automatic news posting to Matrix.
	// When false, stories are still ingested, classified, and served to the
	// web UI, but Pete never auto-posts them to rooms. Command replies
	// (!post, !petestats) still work. Pointer so an absent key defaults to
	// true (posting on) rather than Go's zero-value false.
	Enabled               *bool `toml:"enabled"`
	MinIntervalSeconds    int   `toml:"min_interval_seconds"`
	BurstCapCount         int   `toml:"burst_cap_count"`
	BurstCapWindowSeconds int   `toml:"burst_cap_window_seconds"`
	DedupCooldownHours    int   `toml:"dedup_cooldown_hours"`
	// DailyCapTotal is the hard global cap on posts across ALL channels in a
	// rolling 24-hour window. 0 disables the cap.
	DailyCapTotal int              `toml:"daily_cap_total"`
	RoundRobin    RoundRobinConfig `toml:"round_robin"`
}

// RoundRobinConfig switches Pete from immediate-on-classify posting to a
// paced rotation: one story per IntervalHours, picking the next channel in
// rotation order that has a postable story (skip-and-advance, newest-first).
type RoundRobinConfig struct {
	Enabled       bool `toml:"enabled"`
	IntervalHours int  `toml:"interval_hours"`
}

type StorageConfig struct {
	DBPath            string `toml:"db_path"`
	RecentWindowHours int    `toml:"recent_window_hours"`
}

type SourceConfig struct {
	Name                string `toml:"name"`
	FeedURL             string `toml:"feed_url"`
	Tier                int    `toml:"tier"`
	PollIntervalMinutes int    `toml:"poll_interval_minutes"`
	DirectRoute         string `toml:"direct_route"`
	Enabled             bool   `toml:"enabled"`
	// Language, when set, drops feed items whose per-item language tag is
	// present and does not match (prefix). Useful for multilingual feeds
	// like Politico Europe that publish English + French side-by-side.
	Language string `toml:"language"`
	// UserAgent overrides the User-Agent sent when fetching this feed. Empty
	// uses Pete's honest default bot UA. Set a browser-like string only for
	// sources whose WAF (e.g. AWS WAF on The Portugal News) blocks bot UAs.
	UserAgent string `toml:"user_agent"`
}

func Load(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}

	// Expand only ${VAR} style env references, not bare $VAR
	// (bare $ in passwords like "pa$$word" must not be mangled)
	expanded := envBracketRe.ReplaceAllStringFunc(string(data), func(match string) string {
		varName := match[2 : len(match)-1] // strip ${ and }
		val := os.Getenv(varName)
		if val == "" {
			slog.Warn("config: env var referenced but not set", "var", varName)
		}
		return val
	})

	var cfg Config
	if _, err := toml.Decode(expanded, &cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}

	cfg.applyDefaults()

	if err := cfg.validate(); err != nil {
		return nil, fmt.Errorf("validate config: %w", err)
	}

	return &cfg, nil
}

func (c *Config) validate() error {
	if c.Matrix.Homeserver == "" {
		return fmt.Errorf("matrix.homeserver is required")
	}
	if c.Matrix.UserID == "" {
		return fmt.Errorf("matrix.user_id is required")
	}
	if c.Matrix.Password == "" {
		return fmt.Errorf("matrix.password is required")
	}
	// pickle_key encrypts the E2EE crypto store (olm/megolm + cross-signing
	// keys) at rest. Never fall back to a hardcoded default: a key baked into
	// the source is no protection if crypto.db ever leaks.
	if len(c.Matrix.PickleKey) < 16 {
		return fmt.Errorf("matrix.pickle_key must be set to a strong secret (>=16 chars); it encrypts the E2EE crypto store at rest")
	}
	if len(c.Matrix.Channels) == 0 {
		return fmt.Errorf("matrix.channels must have at least one entry")
	}
	if c.Storage.DBPath == "" {
		return fmt.Errorf("storage.db_path is required")
	}

	if c.Web.Auth.Enabled {
		a := c.Web.Auth
		if a.Issuer == "" || a.ClientID == "" || a.ClientSecret == "" || a.RedirectURL == "" {
			return fmt.Errorf("web.auth requires issuer, client_id, client_secret, and redirect_url when enabled")
		}
		if len(a.SessionSecret) < 16 {
			return fmt.Errorf("web.auth.session_secret must be at least 16 characters")
		}
	}

	if c.Web.Push.Enabled {
		if !c.Web.Auth.Enabled {
			return fmt.Errorf("web.push requires web.auth to be enabled (push is signed-in only)")
		}
		p := c.Web.Push
		if p.VAPIDPublicKey == "" || p.VAPIDPrivateKey == "" {
			return fmt.Errorf("web.push requires vapid_public_key and vapid_private_key (generate with: pete -genvapid)")
		}
		if p.Subject == "" {
			return fmt.Errorf("web.push.subject is required (a mailto: or https: URL identifying the sender)")
		}
	}

	if c.Adventure.Enabled {
		if c.Adventure.IngestToken == "" {
			return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
		}
		if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
			return fmt.Errorf("adventure.digest_hour must be 0-23")
		}
		if c.Adventure.Channel != "" {
			if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
				slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
					"channel", c.Adventure.Channel)
			}
		}
	}

	for i, s := range c.Sources {
		if s.Name == "" {
			return fmt.Errorf("sources[%d].name is required", i)
		}
		if s.FeedURL == "" {
			return fmt.Errorf("sources[%d].feed_url is required", i)
		}
		if s.Tier < 1 || s.Tier > 3 {
			return fmt.Errorf("sources[%d].tier must be 1-3", i)
		}
		if s.PollIntervalMinutes <= 0 {
			return fmt.Errorf("sources[%d].poll_interval_minutes must be > 0", i)
		}
		if !s.Enabled {
			continue
		}
		if s.DirectRoute == "" {
			return fmt.Errorf("sources[%d] (%s): direct_route is required for enabled sources", i, s.Name)
		}
		if _, ok := c.Matrix.Channels[s.DirectRoute]; !ok {
			// Not a Matrix channel — treated as web-only (stories visible in the
			// UI but never posted). Warn so typos still surface.
			slog.Warn("source routes to non-Matrix channel (web-only)",
				"source", s.Name, "direct_route", s.DirectRoute)
		}
	}

	return nil
}

func (c *Config) applyDefaults() {
	if c.Matrix.DataDir == "" {
		c.Matrix.DataDir = "./data"
	}
	if c.Matrix.DisplayName == "" {
		c.Matrix.DisplayName = "Pete"
	}
	if c.Posting.Enabled == nil {
		on := true
		c.Posting.Enabled = &on
	}
	if c.Posting.MinIntervalSeconds == 0 {
		c.Posting.MinIntervalSeconds = 300
	}
	if c.Posting.BurstCapCount == 0 {
		c.Posting.BurstCapCount = 3
	}
	if c.Posting.BurstCapWindowSeconds == 0 {
		c.Posting.BurstCapWindowSeconds = 1800
	}
	if c.Posting.DedupCooldownHours == 0 {
		c.Posting.DedupCooldownHours = 48
	}
	if c.Posting.RoundRobin.IntervalHours == 0 {
		c.Posting.RoundRobin.IntervalHours = 4
	}
	if c.Storage.RecentWindowHours == 0 {
		c.Storage.RecentWindowHours = 24
	}
	if c.Web.ListenAddr == "" {
		c.Web.ListenAddr = ":8080"
	}
	if c.Web.SiteTitle == "" {
		c.Web.SiteTitle = "Pete"
	}
	if c.Web.Push.IntervalMinutes == 0 {
		c.Web.Push.IntervalMinutes = 360
	}
	if c.Web.Push.MinStories == 0 {
		c.Web.Push.MinStories = 3
	}
	for i := range c.Sources {
		if c.Sources[i].PollIntervalMinutes == 0 {
			c.Sources[i].PollIntervalMinutes = 20
		}
	}
}
