// Package store is the persistence layer (PRD §4.2): SQLite behind a Store
// interface so a move to Postgres is a driver swap, not a rewrite.
package store

import (
	"database/sql"
	"errors"
	"time"

	_ "modernc.org/sqlite"

	"github.com/profullstack/agentbbs/internal/games"
)

// User is a persisted account (member or agent; guests are never stored).
type User struct {
	ID            int64
	Name          string
	Kind          string
	PubKeyFP      string
	Email         string
	EmailVerified bool
	Premium       bool   // paid the one-time lifetime membership
	PremiumPayID  string // CoinPay payment id of the pending/settled premium charge
	Banned        bool   // suspended by an admin (blocked at login)
	CreatedAt     time.Time
}

// userCols is the column list (in struct order) for every user SELECT, kept in
// sync with scanUser.
const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, premium_pay_id, banned, created_at`

// scanUser reads one user row selected with userCols.
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
	var u User
	var verified, premium, banned int
	var created string
	if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &u.PremiumPayID, &banned, &created); err != nil {
		return User{}, err
	}
	u.EmailVerified = verified != 0
	u.Premium = premium != 0
	u.Banned = banned != 0
	u.CreatedAt, _ = time.Parse(time.RFC3339, created)
	return u, nil
}

// Score is one leaderboard entry.
type Score struct {
	User  string
	Game  string
	Score int64
	At    time.Time
}

// Store is the persistence contract shared by all plugins.
type Store interface {
	// EnsureUser returns the user with this name, creating it with the given
	// kind and key fingerprint on first sight. If the name exists with a
	// different fingerprint, ErrKeyMismatch is returned.
	EnsureUser(name, kind, pubkeyFP string) (User, error)
	// UserByFingerprint finds an account by SSH key fingerprint.
	UserByFingerprint(fp string) (User, bool, error)
	// UserByName finds an account by exact username (no creation).
	UserByName(name string) (User, bool, error)
	// LastSeen reports the start of the user's most recent session.
	LastSeen(userID int64) (time.Time, bool, error)

	// SetEmailVerification records the account's email and a fresh
	// confirmation token (a link token or a short code), marking it unverified
	// until the token is consumed.
	SetEmailVerification(userID int64, email, token string) error
	// VerifyEmail consumes a confirmation token: on match it marks the
	// account verified, clears the token, and returns the account.
	VerifyEmail(token string) (User, bool, error)
	// ConfirmEmailCode is the interactive (join@) counterpart to VerifyEmail:
	// it matches the code against the one stored for THIS user (codes are
	// short and not globally unique), and on match marks the account verified
	// and clears the code. Returns ok=false on a wrong/empty code.
	ConfirmEmailCode(userID int64, code string) (User, bool, error)

	// SetPremiumPayment records the CoinPay payment id of a pending premium
	// charge so a later visit can verify whether it settled.
	SetPremiumPayment(userID int64, payID string) error
	// GrantPremium marks the account as a lifetime premium member (the $10
	// one-time membership), recording the CoinPay payment reference. Idempotent.
	GrantPremium(userID int64, paymentRef string) error

	RecordSession(userID int64, username, remote, route string) (int64, error)
	EndSession(sessionID int64) error

	AddScore(userID int64, game string, score int64) error
	TopScores(game string, n int) ([]Score, error)

	// Pod subscription (paid membership, e.g. $1/mo via CoinPay).
	PodPaidUntil(userID int64) (time.Time, bool, error)
	GrantPod(userID int64, until time.Time, paymentRef string) error

	// Chat transcripts for the agent@ surface.
	AddChat(userID int64, username, role, text string) error
	RecentChats(username string, n int) ([]ChatMessage, error)

	// Custom domains mapped to a member's homepage (public_html).
	// MapDomain binds domain→username, returning ErrDomainTaken if it is
	// already claimed by someone else (re-binding to the same owner is a no-op).
	MapDomain(domain, username string) error
	UnmapDomain(domain, username string) error
	DomainUser(domain string) (string, bool, error)
	DomainsForUser(username string) ([]string, error)
	AllDomains() ([]DomainMap, error)

	// Admin console (PRD §6). All read-only listings are newest-first.

	// ListUsers returns up to limit accounts, most recently created first.
	ListUsers(limit int) ([]User, error)
	// SetBanned suspends (or restores) an account; banned accounts are blocked
	// at login by the SSH routes.
	SetBanned(userID int64, banned bool) error
	// RecentSessions returns the last n session rows (the audit trail).
	RecentSessions(n int) ([]SessionRow, error)
	// LogAdminAction records one privileged action for the audit log.
	LogAdminAction(admin, action, target, detail string) error
	// RecentAdminActions returns the last n logged admin actions.
	RecentAdminActions(n int) ([]AdminAction, error)
	// RecentChatsAll returns the last n agent@ messages across all users, for
	// moderation review.
	RecentChatsAll(n int) ([]ChatRow, error)
	// DisabledPlugins reports the set of plugin IDs currently switched off.
	DisabledPlugins() (map[string]bool, error)
	// SetPluginDisabled enables or disables a plugin by ID. Idempotent.
	SetPluginDisabled(id string, disabled bool) error

	// AgentGames (PRD §5.2): per-game ELO ladder + replayable match log.

	// Rating returns a player's current rating for a game, or
	// games.DefaultRating if they have no rated history there. It satisfies
	// games.Store so the matchmaker can read ratings.
	Rating(user, game string) (float64, error)
	// SaveMatch records a finished match and upserts both players' ratings.
	// It satisfies games.Store.
	SaveMatch(games.FinishedMatch) error
	// TopRatings returns the n highest-rated players for a game.
	TopRatings(game string, n int) ([]RatingRow, error)
	// RecentMatches returns the last n matches for a game, newest first.
	RecentMatches(game string, n int) ([]MatchRow, error)
	// MatchByID returns one match (with its moves, for replay).
	MatchByID(id int64) (MatchRow, bool, error)

	// MintAPIToken creates and stores a fresh bearer token for the WebSocket
	// game endpoint, bound to username. Returns the token.
	MintAPIToken(username string) (string, error)
	// UserByToken resolves an API token to its account name.
	UserByToken(token string) (string, bool, error)

	Close() error
}

// RatingRow is one ladder entry.
type RatingRow struct {
	User   string
	Rating float64
	Played int
}

// MatchRow is a recorded match, including its moves for replay.
type MatchRow struct {
	ID          int64
	Game        string
	P0          string
	P1          string
	Winner      int
	Reason      string
	Moves       []games.Move
	RatingAfter [2]float64
	StartedAt   time.Time
	EndedAt     time.Time
}

// SessionRow is one connection record from the audit trail.
type SessionRow struct {
	ID         int64
	Username   string
	Remote     string
	Route      string
	Started    time.Time
	Ended      time.Time
	EndedValid bool // false while the session is still open
}

// AdminAction is one entry in the admin audit log.
type AdminAction struct {
	Admin  string
	Action string
	Target string
	Detail string
	At     time.Time
}

// ChatRow is one agent@ message with its author, for moderation review.
type ChatRow struct {
	Username string
	Role     string
	Text     string
	At       time.Time
}

// ChatMessage is one line of an agent@ conversation.
type ChatMessage struct {
	Role string // "user" or "agent"
	Text string
	At   time.Time
}

// DomainMap binds a custom domain to a member's homepage (the public_html that
// is also served at /~name). Used to serve, e.g., https://chovy.com from
// users/chovy/public_html.
type DomainMap struct {
	Domain   string
	Username string
	At       time.Time
}

// ErrKeyMismatch means a username is already registered with another key.
var ErrKeyMismatch = errors.New("username registered with a different key")

// ErrDomainTaken means a domain is already mapped to a different member.
var ErrDomainTaken = errors.New("domain already mapped to another account")

type sqliteStore struct{ db *sql.DB }

// Open opens (and migrates) the SQLite store at path.
func Open(path string) (Store, error) {
	db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
	if err != nil {
		return nil, err
	}
	if _, err := db.Exec(schema); err != nil {
		db.Close()
		return nil, err
	}
	if err := migrate(db); err != nil {
		db.Close()
		return nil, err
	}
	return &sqliteStore{db: db}, nil
}

// migrate applies additive schema changes that must not fail on existing
// databases. New columns live here (not in schema) so there is one code path.
func migrate(db *sql.DB) error {
	return ensureColumns(db, "users", [][2]string{
		{"email", "email TEXT NOT NULL DEFAULT ''"},
		{"email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"},
		{"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"},
		{"premium", "premium INTEGER NOT NULL DEFAULT 0"},
		{"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"},
		{"premium_pay_id", "premium_pay_id TEXT NOT NULL DEFAULT ''"},
		{"banned", "banned INTEGER NOT NULL DEFAULT 0"},
	})
}

// ensureColumns adds any missing {name, "name TYPE …"} columns to table.
func ensureColumns(db *sql.DB, table string, cols [][2]string) error {
	rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
	if err != nil {
		return err
	}
	have := map[string]bool{}
	for rows.Next() {
		var cid, notnull, pk int
		var name, ctype string
		var dflt sql.NullString
		if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
			rows.Close()
			return err
		}
		have[name] = true
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return err
	}
	for _, c := range cols {
		if !have[c[0]] {
			if _, err := db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + c[1]); err != nil {
				return err
			}
		}
	}
	return nil
}

const schema = `
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  kind TEXT NOT NULL,
  pubkey_fp TEXT NOT NULL DEFAULT '',
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS sessions (
  id INTEGER PRIMARY KEY,
  user_id INTEGER,
  username TEXT NOT NULL,
  remote_addr TEXT NOT NULL,
  route TEXT NOT NULL,
  started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  ended_at TEXT
);
CREATE TABLE IF NOT EXISTS scores (
  id INTEGER PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  game TEXT NOT NULL,
  score INTEGER NOT NULL,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_scores_game ON scores(game, score DESC);
CREATE TABLE IF NOT EXISTS chat_messages (
  id INTEGER PRIMARY KEY,
  user_id INTEGER,
  username TEXT NOT NULL,
  role TEXT NOT NULL,
  text TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_messages(username, id);
CREATE TABLE IF NOT EXISTS pod_subscriptions (
  user_id INTEGER PRIMARY KEY REFERENCES users(id),
  paid_until TEXT NOT NULL,
  payment_ref TEXT NOT NULL DEFAULT '',
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS domains (
  domain TEXT PRIMARY KEY,
  username TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_domains_user ON domains(username);
CREATE TABLE IF NOT EXISTS admin_actions (
  id INTEGER PRIMARY KEY,
  admin TEXT NOT NULL,
  action TEXT NOT NULL,
  target TEXT NOT NULL DEFAULT '',
  detail TEXT NOT NULL DEFAULT '',
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_admin_actions_id ON admin_actions(id DESC);
CREATE TABLE IF NOT EXISTS plugin_state (
  id TEXT PRIMARY KEY,
  disabled INTEGER NOT NULL DEFAULT 0,
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS game_ratings (
  username TEXT NOT NULL,
  game TEXT NOT NULL,
  rating REAL NOT NULL,
  played INTEGER NOT NULL DEFAULT 0,
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  PRIMARY KEY (username, game)
);
CREATE INDEX IF NOT EXISTS idx_game_ratings_board ON game_ratings(game, rating DESC);
CREATE TABLE IF NOT EXISTS game_matches (
  id INTEGER PRIMARY KEY,
  game TEXT NOT NULL,
  p0 TEXT NOT NULL,
  p1 TEXT NOT NULL,
  winner INTEGER NOT NULL,
  reason TEXT NOT NULL DEFAULT '',
  moves TEXT NOT NULL DEFAULT '[]',
  r0_before REAL NOT NULL DEFAULT 0,
  r1_before REAL NOT NULL DEFAULT 0,
  r0_after REAL NOT NULL DEFAULT 0,
  r1_after REAL NOT NULL DEFAULT 0,
  started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  ended_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_game_matches_game ON game_matches(game, id DESC);
CREATE TABLE IF NOT EXISTS api_tokens (
  token TEXT PRIMARY KEY,
  username TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(username);
`

func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
	u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name))
	switch {
	case errors.Is(err, sql.ErrNoRows):
		res, err := s.db.Exec(`INSERT INTO users (name, kind, pubkey_fp) VALUES (?,?,?)`, name, kind, fp)
		if err != nil {
			return User{}, err
		}
		id, _ := res.LastInsertId()
		return User{ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil
	case err != nil:
		return User{}, err
	}
	if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp {
		return User{}, ErrKeyMismatch
	}
	return u, nil
}

func (s *sqliteStore) UserByFingerprint(fp string) (User, bool, error) {
	if fp == "" {
		return User{}, false, nil
	}
	u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE pubkey_fp = ?`, fp))
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, false, nil
	}
	if err != nil {
		return User{}, false, err
	}
	return u, true, nil
}

func (s *sqliteStore) SetEmailVerification(userID int64, email, token string) error {
	_, err := s.db.Exec(`UPDATE users SET email = ?, verify_token = ?, email_verified = 0 WHERE id = ?`,
		email, token, userID)
	return err
}

func (s *sqliteStore) VerifyEmail(token string) (User, bool, error) {
	if token == "" {
		return User{}, false, nil
	}
	u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE verify_token = ?`, token))
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, false, nil
	}
	if err != nil {
		return User{}, false, err
	}
	if _, err := s.db.Exec(`UPDATE users SET email_verified = 1, verify_token = '' WHERE id = ?`, u.ID); err != nil {
		return User{}, false, err
	}
	u.EmailVerified = true
	return u, true, nil
}

func (s *sqliteStore) ConfirmEmailCode(userID int64, code string) (User, bool, error) {
	if code == "" {
		return User{}, false, nil
	}
	u, err := scanUser(s.db.QueryRow(
		`SELECT `+userCols+` FROM users WHERE id = ? AND verify_token = ?`, userID, code))
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, false, nil
	}
	if err != nil {
		return User{}, false, err
	}
	if _, err := s.db.Exec(`UPDATE users SET email_verified = 1, verify_token = '' WHERE id = ?`, u.ID); err != nil {
		return User{}, false, err
	}
	u.EmailVerified = true
	return u, true, nil
}

func (s *sqliteStore) SetPremiumPayment(userID int64, payID string) error {
	_, err := s.db.Exec(`UPDATE users SET premium_pay_id = ? WHERE id = ?`, payID, userID)
	return err
}

func (s *sqliteStore) GrantPremium(userID int64, paymentRef string) error {
	_, err := s.db.Exec(`UPDATE users SET premium = 1, premium_ref = ? WHERE id = ?`, paymentRef, userID)
	return err
}

func (s *sqliteStore) RecordSession(userID int64, username, remote, route string) (int64, error) {
	var uid any
	if userID > 0 {
		uid = userID
	}
	res, err := s.db.Exec(`INSERT INTO sessions (user_id, username, remote_addr, route) VALUES (?,?,?,?)`,
		uid, username, remote, route)
	if err != nil {
		return 0, err
	}
	return res.LastInsertId()
}

func (s *sqliteStore) EndSession(id int64) error {
	_, err := s.db.Exec(`UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, id)
	return err
}

func (s *sqliteStore) AddScore(userID int64, game string, score int64) error {
	_, err := s.db.Exec(`INSERT INTO scores (user_id, game, score) VALUES (?,?,?)`, userID, game, score)
	return err
}

func (s *sqliteStore) TopScores(game string, n int) ([]Score, error) {
	rows, err := s.db.Query(`
		SELECT u.name, s.game, s.score, s.created_at
		FROM scores s JOIN users u ON u.id = s.user_id
		WHERE s.game = ? ORDER BY s.score DESC LIMIT ?`, game, n)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Score
	for rows.Next() {
		var sc Score
		var at string
		if err := rows.Scan(&sc.User, &sc.Game, &sc.Score, &at); err != nil {
			return nil, err
		}
		sc.At, _ = time.Parse(time.RFC3339, at)
		out = append(out, sc)
	}
	return out, rows.Err()
}

func (s *sqliteStore) PodPaidUntil(userID int64) (time.Time, bool, error) {
	var until string
	err := s.db.QueryRow(`SELECT paid_until FROM pod_subscriptions WHERE user_id = ?`, userID).Scan(&until)
	if err == sql.ErrNoRows {
		return time.Time{}, false, nil
	}
	if err != nil {
		return time.Time{}, false, err
	}
	t, err := time.Parse(time.RFC3339, until)
	return t, err == nil, err
}

func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error {
	_, err := s.db.Exec(`
		INSERT INTO pod_subscriptions (user_id, paid_until, payment_ref)
		VALUES (?,?,?)
		ON CONFLICT(user_id) DO UPDATE SET
		  paid_until = excluded.paid_until,
		  payment_ref = excluded.payment_ref,
		  updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
		userID, until.UTC().Format(time.RFC3339), ref)
	return err
}

func (s *sqliteStore) UserByName(name string) (User, bool, error) {
	u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name))
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, false, nil
	}
	if err != nil {
		return User{}, false, err
	}
	return u, true, nil
}

func (s *sqliteStore) LastSeen(userID int64) (time.Time, bool, error) {
	var at string
	err := s.db.QueryRow(`SELECT started_at FROM sessions WHERE user_id = ? ORDER BY id DESC LIMIT 1`, userID).Scan(&at)
	if err == sql.ErrNoRows {
		return time.Time{}, false, nil
	}
	if err != nil {
		return time.Time{}, false, err
	}
	t, err := time.Parse(time.RFC3339, at)
	return t, err == nil, err
}

func (s *sqliteStore) AddChat(userID int64, username, role, text string) error {
	var uid any
	if userID > 0 {
		uid = userID
	}
	_, err := s.db.Exec(`INSERT INTO chat_messages (user_id, username, role, text) VALUES (?,?,?,?)`,
		uid, username, role, text)
	return err
}

func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error) {
	rows, err := s.db.Query(`
		SELECT role, text, created_at FROM (
		  SELECT id, role, text, created_at FROM chat_messages
		  WHERE username = ? ORDER BY id DESC LIMIT ?
		) ORDER BY id ASC`, username, n)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []ChatMessage
	for rows.Next() {
		var m ChatMessage
		var at string
		if err := rows.Scan(&m.Role, &m.Text, &at); err != nil {
			return nil, err
		}
		m.At, _ = time.Parse(time.RFC3339, at)
		out = append(out, m)
	}
	return out, rows.Err()
}

func (s *sqliteStore) MapDomain(domain, username string) error {
	var owner string
	err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner)
	switch {
	case err == nil:
		if owner != username {
			return ErrDomainTaken
		}
		return nil // already ours
	case err != sql.ErrNoRows:
		return err
	}
	_, err = s.db.Exec(`INSERT INTO domains (domain, username) VALUES (?,?)`, domain, username)
	return err
}

func (s *sqliteStore) UnmapDomain(domain, username string) error {
	_, err := s.db.Exec(`DELETE FROM domains WHERE domain = ? AND username = ?`, domain, username)
	return err
}

func (s *sqliteStore) DomainUser(domain string) (string, bool, error) {
	var username string
	err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&username)
	if err == sql.ErrNoRows {
		return "", false, nil
	}
	if err != nil {
		return "", false, err
	}
	return username, true, nil
}

func (s *sqliteStore) DomainsForUser(username string) ([]string, error) {
	rows, err := s.db.Query(`SELECT domain FROM domains WHERE username = ? ORDER BY domain`, username)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []string
	for rows.Next() {
		var d string
		if err := rows.Scan(&d); err != nil {
			return nil, err
		}
		out = append(out, d)
	}
	return out, rows.Err()
}

func (s *sqliteStore) AllDomains() ([]DomainMap, error) {
	rows, err := s.db.Query(`SELECT domain, username, created_at FROM domains ORDER BY domain`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []DomainMap
	for rows.Next() {
		var dm DomainMap
		var at string
		if err := rows.Scan(&dm.Domain, &dm.Username, &at); err != nil {
			return nil, err
		}
		dm.At, _ = time.Parse(time.RFC3339, at)
		out = append(out, dm)
	}
	return out, rows.Err()
}

func (s *sqliteStore) ListUsers(limit int) ([]User, error) {
	if limit <= 0 {
		limit = 100
	}
	rows, err := s.db.Query(`SELECT `+userCols+` FROM users ORDER BY id DESC LIMIT ?`, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []User
	for rows.Next() {
		u, err := scanUser(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, u)
	}
	return out, rows.Err()
}

func (s *sqliteStore) SetBanned(userID int64, banned bool) error {
	b := 0
	if banned {
		b = 1
	}
	_, err := s.db.Exec(`UPDATE users SET banned = ? WHERE id = ?`, b, userID)
	return err
}

func (s *sqliteStore) RecentSessions(n int) ([]SessionRow, error) {
	if n <= 0 {
		n = 50
	}
	rows, err := s.db.Query(`
		SELECT id, username, remote_addr, route, started_at, ended_at
		FROM sessions ORDER BY id DESC LIMIT ?`, n)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []SessionRow
	for rows.Next() {
		var r SessionRow
		var started string
		var ended sql.NullString
		if err := rows.Scan(&r.ID, &r.Username, &r.Remote, &r.Route, &started, &ended); err != nil {
			return nil, err
		}
		r.Started, _ = time.Parse(time.RFC3339, started)
		if ended.Valid && ended.String != "" {
			r.Ended, _ = time.Parse(time.RFC3339, ended.String)
			r.EndedValid = true
		}
		out = append(out, r)
	}
	return out, rows.Err()
}

func (s *sqliteStore) LogAdminAction(admin, action, target, detail string) error {
	_, err := s.db.Exec(`INSERT INTO admin_actions (admin, action, target, detail) VALUES (?,?,?,?)`,
		admin, action, target, detail)
	return err
}

func (s *sqliteStore) RecentAdminActions(n int) ([]AdminAction, error) {
	if n <= 0 {
		n = 50
	}
	rows, err := s.db.Query(`
		SELECT admin, action, target, detail, created_at
		FROM admin_actions ORDER BY id DESC LIMIT ?`, n)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []AdminAction
	for rows.Next() {
		var a AdminAction
		var at string
		if err := rows.Scan(&a.Admin, &a.Action, &a.Target, &a.Detail, &at); err != nil {
			return nil, err
		}
		a.At, _ = time.Parse(time.RFC3339, at)
		out = append(out, a)
	}
	return out, rows.Err()
}

func (s *sqliteStore) RecentChatsAll(n int) ([]ChatRow, error) {
	if n <= 0 {
		n = 50
	}
	rows, err := s.db.Query(`
		SELECT username, role, text, created_at
		FROM chat_messages ORDER BY id DESC LIMIT ?`, n)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []ChatRow
	for rows.Next() {
		var c ChatRow
		var at string
		if err := rows.Scan(&c.Username, &c.Role, &c.Text, &at); err != nil {
			return nil, err
		}
		c.At, _ = time.Parse(time.RFC3339, at)
		out = append(out, c)
	}
	return out, rows.Err()
}

func (s *sqliteStore) DisabledPlugins() (map[string]bool, error) {
	rows, err := s.db.Query(`SELECT id FROM plugin_state WHERE disabled = 1`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]bool{}
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		out[id] = true
	}
	return out, rows.Err()
}

func (s *sqliteStore) SetPluginDisabled(id string, disabled bool) error {
	d := 0
	if disabled {
		d = 1
	}
	_, err := s.db.Exec(`
		INSERT INTO plugin_state (id, disabled) VALUES (?,?)
		ON CONFLICT(id) DO UPDATE SET
		  disabled = excluded.disabled,
		  updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, id, d)
	return err
}

func (s *sqliteStore) Close() error { return s.db.Close() }
