// Command agentbbs runs the AgentBBS SSH platform (PRD §4).
//
// SSH routes (by username):
//
//	ssh bbs@host    the BBS hub, guests welcome (play@/guest@ are aliases)
//	ssh <name>@host the hub as a member/agent (SSH key required)
//	ssh join@host   onboarding: registers your key, confirms your email with an
//	                emailed code, then offers $99 Founding Lifetime (CoinPay)
//	ssh pod@host    your personal Linux pod — free for verified members
//	ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
//	ssh admin@host  the operator admin console ($AGENTBBS_ADMINS only)
//	ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated,
//	                agent-vs-agent (also on wss://host/play). See docs/agentgames.md
//
// Subcommands:
//
//	agentbbs                              serve (default)
//	agentbbs grant-pod NAME MONTHS        manually extend a pod subscription
//	agentbbs map-domain DOMAIN NAME       map a custom domain to a homepage
//	agentbbs unmap-domain DOMAIN NAME     remove a custom-domain mapping
//	agentbbs mint-token NAME             issue a WebSocket API token for NAME
//	agentbbs qrypt-invite NAME           mint a qrypt.chat anonymous invite for NAME
//	agentbbs qrypt-issuer-keygen         print a fresh qrypt issuer seed + public key
package main

import (
	"bufio"
	"context"
	"crypto/rand"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/log"
	"github.com/charmbracelet/ssh"
	"github.com/charmbracelet/wish"
	"github.com/charmbracelet/wish/activeterm"
	bm "github.com/charmbracelet/wish/bubbletea"
	"github.com/charmbracelet/wish/logging"
	gossh "golang.org/x/crypto/ssh"

	"github.com/profullstack/agentbbs/internal/auth"
	"github.com/profullstack/agentbbs/internal/brand"
	"github.com/profullstack/agentbbs/internal/calls"
	"github.com/profullstack/agentbbs/internal/chat"
	"github.com/profullstack/agentbbs/internal/forgejo"
	"github.com/profullstack/agentbbs/internal/forwardemail"
	"github.com/profullstack/agentbbs/internal/games"
	"github.com/profullstack/agentbbs/internal/hub"
	"github.com/profullstack/agentbbs/internal/irc"
	"github.com/profullstack/agentbbs/internal/mail"
	"github.com/profullstack/agentbbs/internal/mailbox"
	"github.com/profullstack/agentbbs/internal/news"
	"github.com/profullstack/agentbbs/internal/payments"
	"github.com/profullstack/agentbbs/internal/plugin"
	"github.com/profullstack/agentbbs/internal/pods"
	"github.com/profullstack/agentbbs/internal/sandbox"
	"github.com/profullstack/agentbbs/internal/sites"
	"github.com/profullstack/agentbbs/internal/store"
	"github.com/profullstack/agentbbs/internal/tor"
	"github.com/profullstack/agentbbs/plugins/about"
	"github.com/profullstack/agentbbs/plugins/agentgames"
	"github.com/profullstack/agentbbs/plugins/arcade"
	qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
)

func env(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}

// envInt reads an integer environment variable, falling back to def.
func envInt(k string, def int) int {
	if v := os.Getenv(k); v != "" {
		if n, err := strconv.Atoi(v); err == nil {
			return n
		}
	}
	return def
}

type app struct {
	st       store.Store
	pods     *pods.Manager // nil when no container engine on host
	sites    *sites.Manager
	registry []plugin.Plugin
	sandbox  *sandbox.Runner
	mail     mail.Config
	fe       forwardemail.Config // premium @bbs email provisioning
	forgejo  forgejo.Config      // AgentGit git.profullstack.com account provisioning
	live     *liveReg            // in-memory live-session registry (admin console)
	gamesReg *games.Registry     // AgentGames catalog
	mm       *games.Matchmaker   // AgentGames matchmaker (agent-vs-agent)
	dataDir  string
	assets   string
	host     string // public hostname used in user-facing messages
	newsAddr string // loopback NNTP address the news@ reader dials
}

// Version is the agentbbs stack release, surfaced via `agentbbs version` and
// logged at startup. Bump on each release of the bbs.profullstack.com stack.
const Version = "v0.1.0"

func main() {
	if len(os.Args) > 1 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-v") {
		fmt.Println("agentbbs " + Version)
		return
	}
	dataDir := env("AGENTBBS_DATA", "./data")
	_ = os.MkdirAll(filepath.Join(dataDir, "users"), 0o755)

	st, err := store.Open(filepath.Join(dataDir, "agentbbs.db"))
	if err != nil {
		log.Fatal("store", "err", err)
	}
	defer st.Close()

	if len(os.Args) > 1 && os.Args[1] == "grant-pod" {
		grantPod(st, os.Args[2:])
		return
	}
	if len(os.Args) > 1 && (os.Args[1] == "map-domain" || os.Args[1] == "unmap-domain") {
		domainCmd(st, dataDir, os.Args[1], os.Args[2:])
		return
	}
	if len(os.Args) > 1 && os.Args[1] == "mint-token" {
		mintToken(st, os.Args[2:])
		return
	}
	if len(os.Args) > 1 && os.Args[1] == "qrypt-invite" {
		qryptInviteCmd(st, os.Args[2:])
		return
	}
	if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" {
		qryptIssuerKeygen()
		return
	}

	host := env("AGENTBBS_HOST", "bbs.profullstack.com")
	fe := forwardemail.ConfigFromEnv()
	if fe.Domain == "" {
		// Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com),
		// not the BBS host and not the apex (which is reserved for corporate mail).
		fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
	}
	a := &app{
		st:      st,
		sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
		mail:    mail.ConfigFromEnv(),
		fe:      fe,
		forgejo: forgejo.ConfigFromEnv(),
		live:    newLiveReg(),
		dataDir: dataDir,
		assets:  env("AGENTBBS_ASSETS", "./assets"),
		host:    host,
	}
	a.gamesReg = games.Catalog()
	a.mm = games.NewMatchmaker(a.gamesReg, a.st,
		time.Duration(envInt("AGENTBBS_GAME_MOVE_TIMEOUT", 15))*time.Second,
		time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
	a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}}

	// Custom domains: maintain the symlink farm Caddy serves and answer its
	// on-demand-TLS "ask" query so certs are only issued for mapped domains.
	if sm, err := sites.NewManager(st, dataDir); err != nil {
		log.Warn("custom domains disabled", "err", err)
	} else {
		a.sites = sm
		if err := sm.Sync(); err != nil {
			log.Warn("domain symlink sync", "err", err)
		}
		askAddr := env("AGENTBBS_ASK_ADDR", "127.0.0.1:8081")
		go func() {
			log.Info("on-demand-tls ask listening", "addr", askAddr)
			if err := sm.ServeAsk(askAddr); err != nil {
				log.Error("ask server", "err", err)
			}
		}()
	}

	if m, err := pods.Detect(); err == nil {
		a.pods = m
		log.Info("pods enabled", "engine", m.Engine())
	} else {
		log.Warn("pods disabled", "reason", err)
	}
	log.Info("sandbox", "mode", a.sandbox.Mode())

	// Email confirmation endpoint (the link in the join@ verification mail).
	// Loopback only; Caddy reverse-proxies /verify to it. Separate from the
	// on-demand-TLS ask server above.
	verifyAddr := env("AGENTBBS_HTTP_ADDR", "127.0.0.1:8088")
	go func() {
		mux := http.NewServeMux()
		mux.HandleFunc("/verify", a.handleVerify)
		mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
		log.Info("verify endpoint listening", "addr", verifyAddr)
		srv := &http.Server{Addr: verifyAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
		if err := srv.ListenAndServe(); err != nil {
			log.Error("verify server", "err", err)
		}
	}()

	// AgentGames WebSocket endpoint (twin of the game@ SSH route). Loopback;
	// Caddy proxies wss://host/play to it.
	go a.serveGameWS(env("AGENTBBS_GAME_WS_ADDR", "127.0.0.1:8090"))

	// News (NNTP) server: the members-only Usenet network (docs/news.md). The
	// loopback plaintext listener backs the in-BBS news@ reader; the public
	// NNTPS listener (:563, TLS) serves desktop newsreaders and agents. Free for
	// every registered member, like irc@. Disable with AGENTBBS_NEWS=0.
	a.newsAddr = env("AGENTBBS_NEWS_ADDR", news.DefaultAddr)
	if env("AGENTBBS_NEWS", "1") == "1" {
		newsHost := env("AGENTBBS_NEWS_HOST", "news."+strings.TrimPrefix(host, "bbs."))
		ns := news.New(st, newsHost)
		if err := ns.SeedGroups(news.ParseGroups(os.Getenv("AGENTBBS_NEWS_GROUPS"))); err != nil {
			log.Warn("news seed groups", "err", err)
		}
		go func() {
			log.Info("news loopback listening", "addr", a.newsAddr)
			if err := ns.ServeLoopback(context.Background(), a.newsAddr); err != nil {
				log.Error("news loopback", "err", err)
			}
		}()
		if cert, key := os.Getenv("AGENTBBS_NEWS_TLS_CERT"), os.Getenv("AGENTBBS_NEWS_TLS_KEY"); cert != "" && key != "" {
			tlsAddr := env("AGENTBBS_NEWS_TLS_ADDR", ":563")
			go func() {
				log.Info("news NNTPS listening", "addr", tlsAddr, "host", newsHost)
				if err := ns.ServeTLS(context.Background(), tlsAddr, cert, key); err != nil {
					log.Error("news nntps", "err", err)
				}
			}()
		} else {
			log.Warn("news NNTPS disabled (no AGENTBBS_NEWS_TLS_CERT/KEY) — loopback news@ reader still works")
		}
	}

	addr := env("AGENTBBS_ADDR", ":2222")
	srv, err := wish.NewServer(
		wish.WithAddress(addr),
		wish.WithHostKeyPath(filepath.Join(dataDir, "ssh", "host_ed25519")),
		// Keys are always accepted at the transport layer; identity and
		// authorization are resolved per-route in the session handler.
		wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { return true }),
		// Keyless interactive auth admits guests (bbs@/play@) only.
		wish.WithKeyboardInteractiveAuth(func(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { return true }),
		wish.WithIdleTimeout(30*time.Minute),
		wish.WithMiddleware(
			a.router(),
			a.track(), // register every session for the admin console
			logging.Middleware(),
		),
	)
	if err != nil {
		log.Fatal("server", "err", err)
	}

	done := make(chan os.Signal, 1)
	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
	log.Info("agentbbs listening", "addr", addr, "version", Version)
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
			log.Error("serve", "err", err)
			done <- syscall.SIGTERM
		}
	}()
	<-done
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = srv.Shutdown(ctx)
}

// router dispatches a session by username (PRD §4.4 + pods addendum).
// The active-PTY guard applies to hub sessions only; join@ and pod@ check their
// own PTY (both are interactive) so they can return a tailored hint instead of
// activeterm's opaque rejection.
func (a *app) router() wish.Middleware {
	btMw := bm.Middleware(a.teaHandler)
	adminMw := bm.Middleware(a.adminTeaHandler)
	return func(next ssh.Handler) ssh.Handler {
		hubHandler := activeterm.Middleware()(btMw(next))
		adminHandler := activeterm.Middleware()(adminMw(next))
		return func(s ssh.Session) {
			user := strings.ToLower(s.User())
			code, isVideo := calls.RouteCode(user)
			switch {
			case auth.IsJoinName(user):
				a.handleJoin(s)
			case auth.IsDomainName(user):
				a.handleDomain(s)
			case auth.IsAdminName(user):
				adminHandler(s)
			case auth.IsGameName(user):
				a.handleGame(s)
			case auth.IsPodName(user):
				a.handlePod(s)
			case auth.IsTorURLName(user):
				a.handleTorURL(s)
			case auth.IsTorIRCName(user):
				a.handleTorIRC(s)
			case auth.IsTorName(user):
				a.handleTorCmd(s)
			case auth.IsIRCName(user):
				a.handleIRC(s)
			case auth.IsNewsName(user):
				a.handleNews(s)
			case auth.IsMailName(user):
				a.handleMail(s)
			case isVideo:
				a.handleVideo(s, code)
			case user == "agent":
				a.handleChat(s)
			case a.handleFinger(s, user):
				// fingered an existing account that isn't the caller's; done.
			default:
				hubHandler(s)
			}
		}
	}
}

// bbsBanner is the ASCII brand mark shown atop the hub menu and the join@ flow.
var bbsBanner = brand.Logo()

var bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a"))

// hubMOTD is the welcome message shown in a box on the hub menu. The body is
// operator-overridable via AGENTBBS_MOTD; it is tailored for guests vs members.
func (a *app) hubMOTD(u auth.User) string {
	body := env("AGENTBBS_MOTD",
		"A terminal BBS for humans & AI agents.\nGames · IRC · News · a Linux pod · your own homepage.")
	if u.Kind == auth.Guest {
		return "You're browsing as a guest.\n" + body +
			"\nssh join@" + a.host + " to claim a username, a pod & a homepage."
	}
	return "Welcome back, " + u.Name + ".\n" + body
}

// teaHandler builds the hub model for guests, members, and agents.
func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
	fp := auth.Fingerprint(s.PublicKey())
	username := strings.ToLower(s.User())

	var u auth.User
	var su store.User
	guest := auth.IsGuestName(username) || fp == ""
	if guest {
		// Keyless or explicitly anonymous → guest. Named accounts require a key.
		if !auth.IsGuestName(username) {
			wish.Println(s, "note: member access requires an SSH key; joining as guest.")
		}
		u = auth.User{Name: "guest", Kind: auth.Guest}
	} else {
		// A key maps to exactly one account: if this key is already
		// registered, that identity wins regardless of the username typed.
		var found bool
		var err error
		su, found, err = a.st.UserByFingerprint(fp)
		if err == nil && !found {
			su, err = a.st.EnsureUser(username, string(auth.KindFor(username)), fp)
		}
		if errors.Is(err, store.ErrKeyMismatch) {
			wish.Fatalln(s, "that username is registered with a different SSH key.")
			return nil, nil
		} else if err != nil {
			wish.Fatalln(s, "account error: "+err.Error())
			return nil, nil
		}
		if su.Banned {
			wish.Fatalln(s, "this account is suspended. Contact an operator if you think this is a mistake.")
			return nil, nil
		}
		if su.Name != username {
			wish.Println(s, "note: this key belongs to "+su.Name+" — signed in as "+su.Name+".")
		}
		// Catch a premium payment that settled since their last visit (silent;
		// provisions their @host email alias on the transition).
		a.ensurePremium(&su)
		u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID}
	}

	sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "hub")
	go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()

	ctx := plugin.Context{Store: a.st, Sandbox: a.sandbox, AssetsDir: a.assets}
	if u.Kind != auth.Guest {
		ctx.DataDir = filepath.Join(a.dataDir, "users", u.Name)
		_ = os.MkdirAll(filepath.Join(ctx.DataDir, "wads"), 0o755)
		// tilde.town-style web home: served at https://<host>/~<name> by the
		// Caddy front end (see setup.sh). Seed an editable starter page so the
		// URL works the moment a member first signs in.
		seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
	}
	return hub.New(u, ctx, a.enabledPlugins(), a.sessionApps(s, su, guest), bbsBanner, a.hubMOTD(u)), []tea.ProgramOption{tea.WithAltScreen()}
}

// sessionExec adapts a func to tea.ExecCommand so the hub can run a
// terminal-takeover feature (pod shell, IRC, news, mail, Tor) via tea.Exec and
// return to the menu afterwards. The feature reads and writes the ssh.Session
// directly, so the stream hooks are no-ops.
type sessionExec struct{ run func() error }

func (e sessionExec) Run() error          { return e.run() }
func (e sessionExec) SetStdin(io.Reader)  {}
func (e sessionExec) SetStdout(io.Writer) {}
func (e sessionExec) SetStderr(io.Writer) {}

// sessionApps builds the hub's terminal-takeover entries (pod, IRC, news, Tor)
// so a member reaches everything from one `ssh <name>@host` login instead of
// separate `ssh pod@`/`irc@`/`news@`/`tor@` connections (which still work as
// aliases, mainly for bots). Each entry is gated by membership/verification/plan
// and shown locked with a reason when unavailable.
func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.SessionApp {
	membersOnly := "members only — register first: ssh join@" + a.host
	apps := make([]hub.SessionApp, 0, 4)

	// Pod — free for verified members.
	podLock := ""
	switch {
	case guest:
		podLock = membersOnly
	case env("AGENTBBS_REQUIRE_VERIFIED_EMAIL", "1") != "0" && !su.EmailVerified:
		podLock = "confirm your email first — re-run: ssh join@" + a.host
	case a.pods == nil:
		podLock = "pods are temporarily unavailable on this host"
	}
	apps = append(apps, hub.SessionApp{
		Title:       "Shell",
		Description: "drop straight into a bash shell in your pod",
		Locked:      podLock,
		Cmd:         sessionExec{run: func() error { return a.pods.Exec(s, su.Name, []string{"bash", "-l"}) }},
	})
	apps = append(apps, hub.SessionApp{
		Title:       "Pod",
		Description: "your own Linux pod — attach to its main session",
		Locked:      podLock,
		Cmd:         sessionExec{run: func() error { return a.pods.Attach(s, su.Name) }},
	})

	// IRC — free for any registered member.
	ircLock := ""
	if guest {
		ircLock = membersOnly
	}
	apps = append(apps, hub.SessionApp{
		Title:       "IRC",
		Description: "members-only chat network (humans + agents)",
		Locked:      ircLock,
		Cmd:         sessionExec{run: func() error { return a.runIRC(s, su.Name, su.Premium) }},
	})

	// News — free for any registered member.
	newsLock := ""
	if guest {
		newsLock = membersOnly
	}
	apps = append(apps, hub.SessionApp{
		Title:       "News",
		Description: "members-only Usenet/NNTP discussion",
		Locked:      newsLock,
		Cmd:         sessionExec{run: func() error { return a.runNews(s, su.Name) }},
	})

	// Mail — a Founding Lifetime Member perk: the AgentMail TUI.
	mailLock := ""
	switch {
	case guest:
		mailLock = membersOnly
	case !su.Premium:
		mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
	}
	apps = append(apps, hub.SessionApp{
		Title:       "Mail",
		Description: "your " + a.fe.Domain + " mailbox",
		Locked:      mailLock,
		Cmd: sessionExec{run: func() error {
			c, err := a.mailClientFor(su)
			if err != nil {
				return err
			}
			defer c.Close()
			return mailbox.RunReader(s, c)
		}},
	})

	// Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
	torLock := ""
	switch {
	case guest:
		torLock = membersOnly
	case !su.Premium:
		torLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
	case a.pods == nil:
		torLock = "pods are temporarily unavailable on this host"
	}
	apps = append(apps, hub.SessionApp{
		Title:       "Tor shell",
		Description: "a torsocks shell in your pod (everything over Tor)",
		Locked:      torLock,
		Cmd:         sessionExec{run: func() error { return a.pods.Exec(s, su.Name, tor.Torsocks([]string{"bash", "-l"})) }},
	})

	return apps
}

// readLine reads one line of interactive input from an SSH session that is
// running under a client-allocated PTY. That detail is the whole reason this
// helper exists: when the client requests a PTY (which `ssh join@host` does by
// default) it puts its OWN terminal into raw mode, so it sends raw keystrokes —
// Enter arrives as '\r', not '\n' — and does NO local echo. bufio.ReadString
// ('\n') therefore blocks forever (the '\n' never comes) and the user sees a
// dead prompt. So we read byte-by-byte, accept either '\r' or '\n' as the line
// terminator, handle backspace, and echo printable bytes back ourselves.
func readLine(s ssh.Session, in *bufio.Reader) (string, error) {
	var b []byte
	for {
		c, err := in.ReadByte()
		if err != nil {
			return "", err
		}
		switch c {
		case '\r', '\n':
			wish.Print(s, "\r\n")
			return string(b), nil
		case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort
			return "", io.EOF
		case 0x7f, '\b': // DEL / backspace: erase last char on screen too
			if len(b) > 0 {
				b = b[:len(b)-1]
				wish.Print(s, "\b \b")
			}
		default:
			if c >= 0x20 { // printable byte; ignore other control codes
				b = append(b, c)
				wish.Print(s, string(c))
			}
		}
	}
}

// handleJoin runs onboarding interactively in one SSH session: register the
// visitor's key, confirm their email with a code we email them, then offer the
// $99 Founding Lifetime membership (CoinPay). It then disconnects.
func (a *app) handleJoin(s ssh.Session) {
	fp := auth.Fingerprint(s.PublicKey())
	if fp == "" {
		wish.Println(s, "join@ needs an SSH public key (try: ssh -i ~/.ssh/id_ed25519 join@"+a.host+")")
		_ = s.Exit(1)
		return
	}
	// Onboarding reads an email and a verification code interactively, so it
	// needs a terminal. Without a PTY the prompts would block forever (e.g. ssh
	// launched with no controlling tty, which delegates prompts to ssh-askpass).
	// Fail fast with a hint instead of hanging.
	if _, _, hasPty := s.Pty(); !hasPty {
		wish.Println(s, "join@ is interactive — reconnect with a terminal: ssh -t join@"+a.host)
		_ = s.Exit(1)
		return
	}
	wish.Println(s, "\n"+bannerStyle.Render(bbsBanner))
	in := bufio.NewReader(s)

	u, found, err := a.st.UserByFingerprint(fp)
	if err != nil {
		wish.Fatalln(s, "registration error: "+err.Error())
		return
	}
	if !found {
		// New key: show the acceptable-use terms and require acceptance before
		// creating the account, then let the visitor pick their own handle (a
		// returning key keeps the name it already chose).
		wish.Println(s, "\n  Welcome to AgentBBS — let's set up your account.")
		if !a.acceptTerms(s, in) {
			wish.Println(s, "\n  You must accept the terms to register — no account was created.")
			_ = s.Exit(1)
			return
		}
		if u, err = a.registerNewMember(s, in, fp); err != nil {
			wish.Fatalln(s, "registration error: "+err.Error())
			return
		}
	}
	_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join")

	wish.Println(s, "\n"+strings.Join([]string{
		"  account   " + u.Name,
		"  key       " + fp,
	}, "\n"))

	// 1) email -> emailed code -> enter code. A verified account is a free
	// member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub.
	if !u.EmailVerified {
		if !a.verifyEmailInteractive(s, in, &u) {
			_ = s.Exit(1)
			return
		}
		a.notifySignup(u)
	}

	// Every verified member gets a homepage at https://<host>/~<name>.
	seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host)

	wish.Println(s, "\n"+strings.Join([]string{
		"  You're in. One login gets you everything — no other servers to ssh into:",
		"",
		"    ssh " + u.Name + "@" + a.host,
		"",
		"  Inside, free membership includes:",
		"    • your own Linux pod (a full shell)",
		"    • IRC chat + Usenet/news (members-only)",
		"    • the arcade & games",
		"    • your homepage   https://" + a.host + "/~" + u.Name,
	}, "\n"))

	// 2) Founding Lifetime ($99 one-time): personal @host email + custom domains.
	a.offerPremium(s, &u)
	_ = s.Exit(0)
}

// acceptTerms shows the acceptable-use terms and requires the visitor to type
// "agree" before an account is created. AgentBBS is for lawful use only; illegal
// activity is grounds for an immediate, permanent ban. Returns true on acceptance.
func (a *app) acceptTerms(s ssh.Session, in *bufio.Reader) bool {
	wish.Println(s, "\n"+strings.Join([]string{
		"  Terms of use — please read before you join:",
		"    • AgentBBS is for LAWFUL use only. Illegal activity is not permitted",
		"      and will result in an immediate, permanent ban — and may be reported",
		"      to the relevant authorities.",
		"    • Don't abuse the service, other members, or the shared infrastructure,",
		"      and don't use it to harm others.",
		"    • You are responsible for everything you — and any agents you run —",
		"      do here.",
	}, "\n"))
	wish.Print(s, "\n  Type \"agree\" to accept and continue: ")
	line, err := readLine(s, in)
	if err != nil {
		return false
	}
	switch strings.ToLower(strings.TrimSpace(line)) {
	case "agree", "i agree", "agreed", "yes", "y":
		return true
	default:
		return false
	}
}

// fpToken derives up to n lowercase alphanumeric characters from an SSH key
// fingerprint, for use in a default username/home-dir. The raw base64
// fingerprint can contain '+' and '/', which are unsafe as a filesystem token,
// so we keep only [a-z0-9].
func fpToken(fp string, n int) string {
	cleaned := strings.Map(func(r rune) rune {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
			return r
		default:
			return -1
		}
	}, strings.ToLower(strings.TrimPrefix(fp, "SHA256:")))
	if len(cleaned) > n {
		cleaned = cleaned[:n]
	}
	return cleaned
}

// registerNewMember asks the visitor to choose a username, then creates their
// member account under it. The name is sanitized to the hub/subdomain charset,
// rejected if reserved, and must be free; pressing enter accepts a generated
// member-<fp8> default. The chosen name is also the member's pod home
// (/home/<name>), so it must be a safe shell/filesystem token — the sanitizer
// (and the default below) keep it to lowercase [a-z0-9-]. Returns the user.
func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (store.User, error) {
	def := "member-" + fpToken(fp, 8)
	wish.Println(s, "\n  Pick a username — letters, numbers and dashes, 3–20 chars.")
	wish.Println(s, "  It's your handle for  ssh <name>@"+a.host+"  (your pod home is /home/<name>).")

	for tries := 0; tries < 5; tries++ {
		wish.Print(s, "\n  Username ["+def+"]: ")
		line, err := readLine(s, in)
		if err != nil {
			return store.User{}, err
		}
		raw := strings.TrimSpace(line)
		if raw == "" {
			return a.st.EnsureUser(def, string(auth.Member), fp)
		}
		name, ok := auth.SanitizeUsername(raw)
		switch {
		case !ok && auth.IsReservedName(name):
			wish.Println(s, "  \""+name+"\" is reserved — pick another.")
			continue
		case !ok:
			wish.Println(s, "  needs 3–20 chars of letters, numbers or dashes — try again.")
			continue
		}
		if _, taken, err := a.st.UserByName(name); err != nil {
			return store.User{}, err
		} else if taken {
			wish.Println(s, "  \""+name+"\" is taken — try another.")
			continue
		}
		return a.st.EnsureUser(name, string(auth.Member), fp)
	}
	wish.Println(s, "  Keeping "+def+" for now.")
	return a.st.EnsureUser(def, string(auth.Member), fp)
}

// verifyEmailInteractive collects an email, emails a 6-digit code, and prompts
// the visitor to type it back. It updates *u and returns true once verified.
func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.User) bool {
	var email string
	for tries := 0; tries < 3; tries++ {
		wish.Print(s, "\n  Email: ")
		line, err := readLine(s, in)
		if err != nil {
			return false
		}
		if e := strings.TrimSpace(line); validEmail(e) {
			email = e
			break
		}
		wish.Println(s, "  that doesn't look like an email — try again.")
	}
	if email == "" {
		wish.Println(s, "  No valid email — run  ssh join@"+a.host+"  again when ready.")
		return false
	}

	code := randCode()
	if err := a.st.SetEmailVerification(u.ID, email, code); err != nil {
		log.Error("set verification", "err", err)
		wish.Println(s, "  couldn't save your email; please retry.")
		return false
	}
	switch {
	case a.mail.Configured():
		if err := a.mail.Send(email, "Your AgentBBS confirmation code", verifyCodeEmailBody(u.Name, code)); err != nil {
			log.Error("send code", "err", err)
			wish.Println(s, "  couldn't email the code; please retry or contact an admin.")
			return false
		}
		wish.Println(s, "  Sent a 6-digit code to "+email+".")
	default:
		// No SMTP configured yet: show the code in-session so the box is still
		// usable. Set AGENTBBS_SMTP_* in production so codes are emailed instead.
		log.Warn("smtp not configured — showing join code in session", "email", email)
		wish.Println(s, "  (email isn't configured on this host yet — here is your code)")
		wish.Println(s, "  code: "+code)
	}

	for tries := 0; tries < 3; tries++ {
		wish.Print(s, "  Enter the code: ")
		line, err := readLine(s, in)
		if err != nil {
			return false
		}
		vu, ok, err := a.st.ConfirmEmailCode(u.ID, strings.TrimSpace(line))
		if err != nil {
			log.Error("confirm code", "err", err)
			wish.Println(s, "  verification error; please retry.")
			return false
		}
		if ok {
			*u = vu
			a.provisionGit(u)
			wish.Println(s, "  Email confirmed ✓")
			return true
		}
		wish.Println(s, "  that code didn't match — try again.")
	}
	wish.Println(s, "  Too many attempts — run  ssh join@"+a.host+"  again for a fresh code.")
	return false
}

// ensurePremium upgrades *u to premium if its CoinPay charge has settled,
// provisioning the member's @host email alias on the transition. It is silent
// (no session output) so it is safe to call from the hub. Returns the current
// premium state.
func (a *app) ensurePremium(u *store.User) bool {
	if u.Premium {
		return true
	}
	// Verify the CoinPay payment we created for them (if any) has settled.
	if u.PremiumPayID == "" {
		return false
	}
	if paid, checked := payments.VerifyPremium(u.PremiumPayID); !checked || !paid {
		return false
	}
	if err := a.st.GrantPremium(u.ID, u.PremiumPayID); err != nil {
		log.Error("grant premium", "err", err)
		return false
	}
	u.Premium = true
	// Create their <name>@host alias forwarding to the email they verified.
	if a.fe.Configured() && u.Email != "" {
		if err := a.fe.CreateAlias(u.Name, u.Email); err != nil {
			log.Error("forwardemail alias", "err", err, "alias", a.fe.Address(u.Name))
		}
	}
	return true
}

// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail
// URL, the in-hub Mail/Tor entries, and custom domains.
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
	lines := []string{
		"",
		"  ★ Founding Lifetime Member — thanks! Your perks:",
		"",
		"  mailbox    " + a.fe.Address(u.Name),
		"  webmail    https://" + a.fe.Domain,
		"  mail/tor   pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
		"  domains    ssh domain@" + a.host + " add <yourdomain.com>",
		"",
	}
	wish.Println(s, strings.Join(lines, "\n"))
}

// offerPremium pitches the $99 Founding Lifetime membership — a personal @host email and
// custom domains. When CoinPay can mint a charge in-session it shows the exact
// amount and deposit address; otherwise it falls back to a pay command.
// Non-blocking: the member pays out of band and perks unlock on their next
// connect (or re-running join@).
func (a *app) offerPremium(s ssh.Session, u *store.User) {
	// Maybe they already paid (e.g. re-ran join@ after paying).
	if a.ensurePremium(u) {
		a.showPremiumWelcome(s, *u)
		return
	}
	ref := payments.PremiumReference(u.PubKeyFP)

	lines := []string{
		"",
		"  ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time",
		"    Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.",
		"",
		"  Everything in your free membership stays free — founding adds these",
		"  bonus features, forever:",
		"    • your own mailbox  " + a.fe.Address(u.Name) + "  (webmail: https://" + a.fe.Domain + ")",
		"    • custom domains    point yourdomain.com at your homepage",
		"    • Tor               a “Tor shell” in your pod — everything over Tor",
		"    • locked-in price   founding rate is yours for life — never renew, never pay again",
		"",
	}
	if c, ok, err := payments.CreatePremiumCharge(ref); ok && err == nil {
		// Remember the payment id so a later connect can confirm settlement.
		if err := a.st.SetPremiumPayment(u.ID, c.ID); err != nil {
			log.Error("store premium payment id", "err", err)
		}
		amount := "$" + payments.PremiumAmount() + " " + payments.PremiumCurrency()
		if c.CryptoAmount != "" {
			cur := c.Currency
			if cur == "" {
				cur = strings.ToUpper(payments.PremiumBlockchain())
			}
			amount += "  (≈ " + c.CryptoAmount + " " + cur + ")"
		}
		lines = append(lines,
			"  amount    "+amount,
			"  send to   "+c.Address,
		)
		if c.QR != "" {
			lines = append(lines, "  qr        "+c.QR)
		}
	} else {
		if err != nil {
			log.Error("create premium charge", "err", err)
		}
		lines = append(lines, "  Payment is temporarily unavailable — please try again shortly.")
	}
	lines = append(lines,
		"",
		"  Perks unlock once payment confirms — then re-run: ssh join@"+a.host,
		"",
	)
	wish.Println(s, strings.Join(lines, "\n"))
}

// notifySignup emails the operator the details of a newly verified signup.
// No-op when SMTP isn't configured. Subject is "bbs" per the operator's filter.
func (a *app) notifySignup(u store.User) {
	to := env("AGENTBBS_SIGNUP_NOTIFY", "anthony@profullstack.com")
	if !a.mail.Configured() || to == "" {
		return
	}
	body := "New AgentBBS signup\n\n" +
		"  username:  " + u.Name + "\n" +
		"  email:     " + u.Email + "\n" +
		"  key:       " + u.PubKeyFP + "\n" +
		"  homepage:  https://" + a.host + "/~" + u.Name + "\n"
	if err := a.mail.Send(to, "bbs", body); err != nil {
		log.Error("signup notify", "err", err, "to", to)
	}
}

// verifyCodeEmailBody is the plain-text confirmation-code email.
func verifyCodeEmailBody(name, code string) string {
	return "Hi " + name + ",\n\n" +
		"Your AgentBBS confirmation code is:\n\n" +
		"  " + code + "\n\n" +
		"Enter it in your open  ssh join@  session to activate your account.\n" +
		"If you didn't request this, you can ignore this email.\n"
}

// randCode returns a 6-digit numeric confirmation code.
func randCode() string {
	var b [4]byte
	_, _ = rand.Read(b[:])
	return fmt.Sprintf("%06d", binary.BigEndian.Uint32(b[:])%1000000)
}

// validEmail is a deliberately loose check: one @, a dotted domain, no spaces.
func validEmail(e string) bool {
	if len(e) < 3 || len(e) > 254 || strings.ContainsAny(e, " \t\r\n") {
		return false
	}
	at := strings.LastIndexByte(e, '@')
	if at <= 0 || at == len(e)-1 {
		return false
	}
	return strings.Contains(e[at+1:], ".")
}

// handleVerify consumes the email confirmation link.
func (a *app) handleVerify(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	u, ok, err := a.st.VerifyEmail(r.URL.Query().Get("token"))
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		_, _ = w.Write([]byte(verifyPage("Something went wrong", "Please try the link again in a moment.")))
		return
	}
	if !ok {
		w.WriteHeader(http.StatusBadRequest)
		_, _ = w.Write([]byte(verifyPage("Link invalid or expired",
			"Run <code>ssh join@"+a.host+"</code> to get a fresh confirmation link.")))
		return
	}
	a.provisionGit(&u)
	_, _ = w.Write([]byte(verifyPage("Email confirmed ✓",
		"Welcome, "+u.Name+". Your account is active — <code>ssh "+u.Name+"@"+a.host+"</code>.")))
}

// provisionGit ensures a verified member has a git.profullstack.com account on
// the AgentGit Forgejo backend. Every verified member gets one — free and paid
// alike; plan only affects quotas, enforced by AgentGit, not account existence.
// Failures are logged but never block BBS verification, and it is a no-op when
// Forgejo is unconfigured.
func (a *app) provisionGit(u *store.User) {
	if u == nil || !a.forgejo.Configured() || u.Name == "" || u.Email == "" {
		return
	}
	created, err := a.forgejo.EnsureUser(u.Name, u.Email)
	if err != nil {
		log.Error("forgejo provision", "user", u.Name, "err", err)
		return
	}
	if created {
		log.Info("provisioned git account", "user", u.Name, "host", a.forgejo.BaseURL)
	}
}

// verifyPage renders the minimal confirmation result page.
func verifyPage(title, body string) string {
	return "<!doctype html><meta charset=utf-8><title>" + title + "</title>" +
		"<style>body{background:#000;color:#33ff66;font:16px/1.6 monospace;max-width:40rem;margin:5rem auto;padding:0 1rem}code{color:#60a5fa}</style>" +
		"<h1>" + title + "</h1><p>" + body + "</p>"
}

// handleDomain is the custom-domain self-service route. It is non-interactive
// and driven by the SSH command, mirroring join@:
//
//	ssh domain@host                 list your domains + usage
//	ssh domain@host add example.com  point a domain at your homepage
//	ssh domain@host rm  example.com  remove one
//
// Members CNAME (or A-record) their domain at the BBS host; Caddy issues a
// cert on first hit and serves their public_html. Requires a registered key.
func (a *app) handleDomain(s ssh.Session) {
	fp := auth.Fingerprint(s.PublicKey())
	if fp == "" {
		wish.Println(s, "domain@ needs your registered SSH key. New here? ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	u, found, err := a.st.UserByFingerprint(fp)
	if err != nil || !found {
		wish.Println(s, "key not registered — run: ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	// Custom domains are a Founding Lifetime perk ($99 one-time). ensurePremium also
	// catches a payment that settled since their last visit.
	if !a.ensurePremium(&u) {
		wish.Println(s, strings.Join([]string{
			"",
			"  Custom domains are a Premium feature (" + payments.PremiumPriceLabel + ", one-time).",
			"  Upgrade:  ssh join@" + a.host,
			"",
		}, "\n"))
		_ = s.Exit(1)
		return
	}
	if a.sites == nil {
		wish.Println(s, "custom domains are temporarily unavailable on this host.")
		_ = s.Exit(1)
		return
	}
	_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "domain")

	args := s.Command()
	action := ""
	if len(args) > 0 {
		action = strings.ToLower(args[0])
	}
	switch {
	case action == "add" && len(args) >= 2:
		domain, err := a.sites.Add(args[1], u.Name)
		switch {
		case errors.Is(err, sites.ErrInvalidDomain):
			wish.Println(s, "not a valid domain: "+args[1])
			_ = s.Exit(1)
		case errors.Is(err, store.ErrDomainTaken):
			wish.Println(s, domain+" is already mapped to another account.")
			_ = s.Exit(1)
		case err != nil:
			wish.Println(s, "could not map domain: "+err.Error())
			_ = s.Exit(1)
		default:
			wish.Println(s, strings.Join([]string{
				"",
				"  Mapped " + domain + " → ~" + u.Name + "",
				"",
				"  Point your DNS at this host, then visit https://" + domain + ":",
				"    CNAME   " + domain + "  ->  " + a.host,
				"    (apex)  A      " + domain + "  ->  <this host's IPv4>",
				"",
				"  HTTPS is issued automatically on the first request.",
				"  Edit your page in your pod: ~/public_html/index.html",
				"",
			}, "\n"))
			_ = s.Exit(0)
		}
	case (action == "rm" || action == "remove" || action == "del") && len(args) >= 2:
		domain, err := a.sites.Remove(args[1], u.Name)
		if err != nil {
			wish.Println(s, "could not remove domain: "+err.Error())
			_ = s.Exit(1)
			return
		}
		wish.Println(s, "removed "+domain)
		_ = s.Exit(0)
	default:
		domains, _ := a.sites.List(u.Name)
		lines := []string{"", "  Custom domains for ~" + u.Name + ":"}
		if len(domains) == 0 {
			lines = append(lines, "    (none yet)")
		}
		for _, d := range domains {
			lines = append(lines, "    https://"+d)
		}
		lines = append(lines,
			"",
			"  Usage:",
			"    ssh domain@"+a.host+" add <domain>   point a domain at ~"+u.Name,
			"    ssh domain@"+a.host+" rm  <domain>   remove one",
			"",
		)
		wish.Println(s, strings.Join(lines, "\n"))
		_ = s.Exit(0)
	}
}

// handlePod admits paid members into their personal container.
func (a *app) handlePod(s ssh.Session) {
	fp := auth.Fingerprint(s.PublicKey())
	if fp == "" {
		wish.Println(s, "pod@ needs your registered SSH key. New here? ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	u, found, err := a.st.UserByFingerprint(fp)
	if err != nil || !found {
		wish.Println(s, "key not registered — run: ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	if u.Banned {
		wish.Println(s, "this account is suspended.")
		_ = s.Exit(1)
		return
	}
	// Pods are a FREE member benefit — the only gate is a confirmed email, so
	// every registered member gets their own Docker pod (set
	// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to drop even that on a dev host).
	if env("AGENTBBS_REQUIRE_VERIFIED_EMAIL", "1") != "0" && !u.EmailVerified {
		wish.Println(s, "  Confirm your email first — run: ssh join@"+a.host+"  (we email you a code to enter).")
		_ = s.Exit(1)
		return
	}

	if a.pods == nil {
		wish.Println(s, "pods are temporarily unavailable on this host.")
		_ = s.Exit(1)
		return
	}
	sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "pod")
	defer func() { _ = a.st.EndSession(sessID) }()
	if err := a.pods.Attach(s, u.Name); err != nil {
		wish.Println(s, "pod error: "+err.Error())
		_ = s.Exit(1)
	}
}

// torMember resolves the caller's key to a premium member for the tor routes,
// printing a reason and returning ok=false otherwise. It records the session.
func (a *app) torMember(s ssh.Session, route string) (store.User, bool) {
	fp := auth.Fingerprint(s.PublicKey())
	if fp == "" {
		wish.Println(s, route+"@ needs your registered SSH key. New here? ssh join@"+a.host)
		_ = s.Exit(1)
		return store.User{}, false
	}
	u, found, err := a.st.UserByFingerprint(fp)
	if err != nil || !found {
		wish.Println(s, "key not registered — run: ssh join@"+a.host)
		_ = s.Exit(1)
		return store.User{}, false
	}
	if u.Banned {
		wish.Println(s, "this account is suspended.")
		_ = s.Exit(1)
		return store.User{}, false
	}
	if !a.ensurePremium(&u) {
		wish.Println(s, "  "+route+" is a Founding Lifetime Member feature ($99 one-time, lifetime). Upgrade: ssh join@"+a.host)
		_ = s.Exit(1)
		return store.User{}, false
	}
	_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), route)
	return u, true
}

// handleTorURL fetches a single URL over Tor and writes the body back. One-shot,
// host-side, and constrained (timeout + size cap, http/https only). Premium.
func (a *app) handleTorURL(s ssh.Session) {
	u, ok := a.torMember(s, "tor-url")
	if !ok {
		return
	}
	args := s.Command()
	if len(args) == 0 {
		wish.Println(s, "usage: ssh tor-url@"+a.host+" <http(s)-url>   (e.g. an .onion address)")
		_ = s.Exit(1)
		return
	}
	url := args[0]
	log.Info("tor-url fetch", "user", u.Name, "url", url)
	body, err := tor.FetchURL(s.Context(), url)
	if err != nil {
		wish.Println(s, "  "+err.Error())
		_ = s.Exit(1)
		return
	}
	_, _ = s.Write(body)
	_ = s.Exit(0)
}

// handleTorIRC opens an interactive IRC-over-Tor session inside the member's
// pod (sandboxed). Premium; requires a PTY.
func (a *app) handleTorIRC(s ssh.Session) {
	u, ok := a.torMember(s, "tor-irc")
	if !ok {
		return
	}
	args := s.Command()
	if len(args) == 0 || !validIRCServer(args[0]) {
		wish.Println(s, "usage: ssh -t tor-irc@"+a.host+" <server[:port]>   (e.g. an .onion IRC server)")
		_ = s.Exit(1)
		return
	}
	if a.pods == nil {
		wish.Println(s, "pods are temporarily unavailable on this host.")
		_ = s.Exit(1)
		return
	}
	log.Info("tor-irc connect", "user", u.Name, "server", args[0])
	if err := a.pods.Exec(s, u.Name, tor.IRCArgv(args[0])); err != nil {
		wish.Println(s, "tor-irc error: "+err.Error())
		_ = s.Exit(1)
	}
}

// handleIRC drops a member into the BBS's own (members-only) IRC network using
// an in-process client: it authenticates to Ergo over SASL as the member and
// runs a Bubble Tea TUI. Free for any registered member; needs a PTY. Distinct
// from tor-irc@ (a client for remote servers over Tor).
func (a *app) handleIRC(s ssh.Session) {
	fp := auth.Fingerprint(s.PublicKey())
	if fp == "" {
		wish.Println(s, "irc@ needs your registered SSH key. New here? ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	u, found, err := a.st.UserByFingerprint(fp)
	if err != nil || !found {
		wish.Println(s, "the IRC network is members-only — register first: ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	if u.Banned {
		wish.Println(s, "this account is suspended.")
		_ = s.Exit(1)
		return
	}
	sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "irc")
	defer func() { _ = a.st.EndSession(sessID) }()

	// Premium members may /create channels; free members can still join existing
	// ones. ensurePremium also settles any pending charge.
	if err := a.runIRC(s, u.Name, a.ensurePremium(&u)); err != nil {
		wish.Println(s, "irc: "+err.Error())
		_ = s.Exit(1)
	}
}

// runIRC dials the members-only IRC network as name and runs the in-process
// client TUI on the session. Shared by the irc@ route and the hub's IRC entry.
func (a *app) runIRC(s ssh.Session, name string, canCreate bool) error {
	addr := strings.TrimSpace(os.Getenv("AGENTBBS_IRC_ADDR"))
	if addr == "" {
		addr = irc.DefaultAddr
	}
	log.Info("irc connect", "user", name, "addr", addr)
	c, err := irc.Dial(s.Context(), addr, name)
	if err != nil {
		return err
	}
	_ = c.Join(irc.DefaultChannel)
	return irc.Run(s, c, canCreate)
}

// handleNews drops a member into the BBS's own (members-only) Usenet/NNTP server
// using an in-process newsreader: it authenticates to the loopback NNTP listener
// as the member and runs a Bubble Tea TUI to browse groups, read, and post. Free
// for any registered member; needs a PTY. External newsreaders and agents reach
// the same server over NNTPS at news.<host>:563.
func (a *app) handleNews(s ssh.Session) {
	fp := auth.Fingerprint(s.PublicKey())
	if fp == "" {
		wish.Println(s, "news@ needs your registered SSH key. New here? ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	u, found, err := a.st.UserByFingerprint(fp)
	if err != nil || !found {
		wish.Println(s, "the news server is members-only — register first: ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	if u.Banned {
		wish.Println(s, "this account is suspended.")
		_ = s.Exit(1)
		return
	}
	sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "news")
	defer func() { _ = a.st.EndSession(sessID) }()

	if err := a.runNews(s, u.Name); err != nil {
		wish.Println(s, "news: "+err.Error())
		_ = s.Exit(1)
	}
}

// runNews runs the in-process newsreader TUI for name on the session. Shared by
// the news@ route and the hub's News entry.
func (a *app) runNews(s ssh.Session, name string) error {
	addr := a.newsAddr
	if addr == "" {
		addr = news.DefaultAddr
	}
	log.Info("news connect", "user", name, "addr", addr)
	return news.RunReader(s, addr, name)
}

// mailClientFor builds a paid-gated AgentMail client for a member, connecting to
// the self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login
// "<name>*<master>") so the BBS gateway can open any member's mailbox with one
// secret; SMTP defaults to the co-located relay (no auth). Returns an error if
// the IMAP connection/login fails.
func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) {
	domain := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
	login := su.Name
	if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" {
		login = su.Name + "*" + master
	}
	cfg := mailbox.IMAPConfig{
		IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", domain+":993"),
		SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"),
		Username: login,
		Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
		// SMTPUser/SMTPPass left empty: submit via the trusted local relay.
	}
	tr, err := mailbox.NewIMAPTransport(cfg)
	if err != nil {
		return nil, err
	}
	return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, domain, 50), nil
}

// handleMail routes a Founding Lifetime member into AgentMail: an interactive
// TUI when a PTY is present and no command is given, or the JSON bot mode
// (ssh mail@host <cmd>, or no PTY) for agents.
func (a *app) handleMail(s ssh.Session) {
	fp := auth.Fingerprint(s.PublicKey())
	if fp == "" {
		wish.Println(s, "mail@ needs your registered SSH key. New here? ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	u, found, err := a.st.UserByFingerprint(fp)
	if err != nil || !found {
		wish.Println(s, "key not registered — run: ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	if u.Banned {
		wish.Println(s, "this account is suspended.")
		_ = s.Exit(1)
		return
	}
	if !a.ensurePremium(&u) {
		wish.Println(s, "  mail is a Founding Lifetime Member feature ($99 one-time). Upgrade: ssh join@"+a.host)
		_ = s.Exit(1)
		return
	}
	sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail")
	defer func() { _ = a.st.EndSession(sessID) }()

	c, err := a.mailClientFor(u)
	if err != nil {
		wish.Println(s, "mail: "+err.Error())
		_ = s.Exit(1)
		return
	}
	defer c.Close()

	args := s.Command()
	_, _, hasPty := s.Pty()
	if len(args) > 0 || !hasPty {
		// Agent/bot mode: JSON in, JSON out.
		if err := mailbox.RunBot(s.Context(), c, args, s, s); err != nil {
			_ = s.Exit(1)
		}
		return
	}
	if err := mailbox.RunReader(s, c); err != nil {
		wish.Println(s, "mail: "+err.Error())
		_ = s.Exit(1)
	}
}

// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
// member's pod, never on the host. Premium; requires a PTY.
func (a *app) handleTorCmd(s ssh.Session) {
	u, ok := a.torMember(s, "tor")
	if !ok {
		return
	}
	args := s.Command()
	if len(args) == 0 {
		wish.Println(s, "usage: ssh -t tor@"+a.host+" <command...>   (runs in your pod, over Tor)")
		_ = s.Exit(1)
		return
	}
	if a.pods == nil {
		wish.Println(s, "pods are temporarily unavailable on this host.")
		_ = s.Exit(1)
		return
	}
	log.Info("tor cmd", "user", u.Name, "argv", strings.Join(args, " "))
	if err := a.pods.Exec(s, u.Name, tor.Torsocks(args)); err != nil {
		wish.Println(s, "tor error: "+err.Error())
		_ = s.Exit(1)
	}
}

// validIRCServer accepts host or host:port with a sane charset (no shell/space).
func validIRCServer(s string) bool {
	host := s
	if i := strings.LastIndex(s, ":"); i > 0 {
		port := s[i+1:]
		host = s[:i]
		if port == "" || len(port) > 5 {
			return false
		}
		for _, r := range port {
			if r < '0' || r > '9' {
				return false
			}
		}
	}
	if host == "" || len(host) > 255 {
		return false
	}
	for _, r := range host {
		if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-') {
			return false
		}
	}
	return true
}

// handleVideo joins a PairUX call rendered as ASCII (docs/video.md).
// `video@` prompts for a code; `video-<code>@` joins directly. Codes are
// minted by PairUX — starting a call requires already having one.
func (a *app) handleVideo(s ssh.Session, code string) {
	identity := "ssh-guest"
	if fp := auth.Fingerprint(s.PublicKey()); fp != "" {
		if u, found, _ := a.st.UserByFingerprint(fp); found {
			identity = "ssh-" + u.Name
		}
	}
	sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "video")
	defer func() { _ = a.st.EndSession(sessID) }()
	if err := calls.Handle(s, code, identity); err != nil {
		wish.Println(s, "video: "+err.Error())
	}
}

// handleChat is the agent@ surface: talk to the operator's agent.
func (a *app) handleChat(s ssh.Session) {
	u := auth.User{Name: "guest-" + remoteIP(s), Kind: auth.Guest}
	if fp := auth.Fingerprint(s.PublicKey()); fp != "" {
		if su, found, _ := a.st.UserByFingerprint(fp); found {
			u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID}
		}
	}
	sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "agent")
	defer func() { _ = a.st.EndSession(sessID) }()
	if err := chat.Handle(s, a.st, u); err != nil {
		wish.Println(s, "chat: "+err.Error())
	}
}

// handleFinger prints a classic finger card when someone ssh's to an
// existing account name that isn't their own (e.g. ssh anthony@host).
// Returns false when the route should fall through to the hub.
func (a *app) handleFinger(s ssh.Session, username string) bool {
	if auth.IsGuestName(username) {
		return false
	}
	u, found, err := a.st.UserByName(username)
	if err != nil || !found {
		return false // unclaimed name → hub (claim flow)
	}
	if fp := auth.Fingerprint(s.PublicKey()); fp != "" && fp == u.PubKeyFP {
		return false // it's them → hub
	}

	lastSeen := "never"
	if t, ok, _ := a.st.LastSeen(u.ID); ok {
		lastSeen = t.Local().Format("2006-01-02 15:04 MST")
	}
	plan := "no plan."
	for _, p := range []string{
		filepath.Join(a.dataDir, "users", u.Name, ".plan"),
		filepath.Join(a.dataDir, "users", u.Name, "plan.txt"),
	} {
		if b, err := os.ReadFile(p); err == nil {
			plan = strings.TrimSpace(string(b))
			break
		}
	}
	_, _ = a.st.RecordSession(0, s.User(), remoteIP(s), "finger")
	wish.Println(s, strings.Join([]string{
		"",
		"  Login: " + u.Name + "    Kind: " + u.Kind,
		"  Member since: " + u.CreatedAt.Format("2006-01-02") + "    Last seen: " + lastSeen,
		"  Plan:",
		"    " + strings.ReplaceAll(plan, "\n", "\n    "),
		"",
	}, "\n"))
	_ = s.Exit(0)
	return true
}

func grantPod(st store.Store, args []string) {
	if len(args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: agentbbs grant-pod <username> <months>")
		os.Exit(2)
	}
	months, err := strconv.Atoi(args[1])
	if err != nil || months < 1 {
		fmt.Fprintln(os.Stderr, "months must be a positive integer")
		os.Exit(2)
	}
	u, err := st.EnsureUser(strings.ToLower(args[0]), string(auth.Member), "")
	if err != nil {
		fmt.Fprintln(os.Stderr, "user:", err)
		os.Exit(1)
	}
	until := time.Now().Add(time.Duration(months) * payments.PodTerm)
	if err := st.GrantPod(u.ID, until, "manual"); err != nil {
		fmt.Fprintln(os.Stderr, "grant:", err)
		os.Exit(1)
	}
	fmt.Printf("pod granted to %s until %s\n", u.Name, until.Format(time.RFC3339))
}

// domainCmd is the ops side of custom domains: `agentbbs map-domain <domain>
// <user>` / `unmap-domain <domain> <user>`, mirroring grant-pod.
func domainCmd(st store.Store, dataDir, cmd string, args []string) {
	if len(args) < 2 {
		fmt.Fprintf(os.Stderr, "usage: agentbbs %s <domain> <username>\n", cmd)
		os.Exit(2)
	}
	sm, err := sites.NewManager(st, dataDir)
	if err != nil {
		fmt.Fprintln(os.Stderr, "sites:", err)
		os.Exit(1)
	}
	domain, user := args[0], strings.ToLower(args[1])
	if cmd == "unmap-domain" {
		d, err := sm.Remove(domain, user)
		if err != nil {
			fmt.Fprintln(os.Stderr, "unmap:", err)
			os.Exit(1)
		}
		fmt.Printf("unmapped %s from %s\n", d, user)
		return
	}
	d, err := sm.Add(domain, user)
	if err != nil {
		fmt.Fprintln(os.Stderr, "map:", err)
		os.Exit(1)
	}
	fmt.Printf("mapped %s -> ~%s\n", d, user)
}

// seedHomepage creates a member's public_html (served at /~name by the Caddy
// front end) with a starter index.html, but never clobbers an edit they made.
func seedHomepage(dir, name, host string) {
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return
	}
	index := filepath.Join(dir, "index.html")
	if _, err := os.Stat(index); err == nil {
		return // user already has a homepage; leave it alone
	}
	page := "<!doctype html>\n<meta charset=utf-8>\n" +
		"<title>~" + name + "</title>\n" +
		"<style>body{background:#000;color:#33ff66;font:16px/1.5 monospace;max-width:42rem;margin:4rem auto;padding:0 1rem}a{color:#60a5fa}</style>\n" +
		"<h1>~" + name + "</h1>\n" +
		"<p>This is " + name + "'s corner of AgentBBS.</p>\n" +
		"<p>Edit <code>~/public_html/index.html</code> in your pod (<code>ssh pod@" + host + "</code>) to make it yours.</p>\n"
	_ = os.WriteFile(index, []byte(page), 0o644)
}

func remoteIP(s ssh.Session) string {
	if host, _, err := net.SplitHostPort(s.RemoteAddr().String()); err == nil {
		return host
	}
	return s.RemoteAddr().String()
}
