// Package files implements member file storage for AgentBBS, reachable over
// SFTP with the member's existing SSH login key (docs/files.md). It is wired as
// an "sftp" subsystem on the shared wish server (port 22), so a member runs
//
//	sftp files@bbs.profullstack.com
//
// and lands in a virtual filesystem with two areas:
//
//	/me      — their private, per-user workspace (quota-limited)
//	/public  — a single shared file area (old-school BBS file area); world-read,
//	           members-only write by default, operator-moderated.
//
// Identity is the SSH public key (one key = one account, like the rest of the
// BBS); the SFTP username is conventional ("files") and ignored. The server is a
// fully virtual Go SFTP server (github.com/pkg/sftp) — there are no OS users.
//
// Security: every path the client supplies is resolved through resolve() in
// fs.go, which confines it to its area root (no traversal, no symlink escape);
// see the path-traversal tests. Per §9.2 the operator can inspect and act on
// hosted files (the management TUI), and per §9.3 (amended) the only sharing
// surface is the single public area — there is no workspace-to-workspace
// transfer.
package files

import (
	_ "embed"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"sync"
	"sync/atomic"
	"time"

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

// defaultPublicReadme seeds every member's public area so ~<name>/public is
// never a bare "(empty)" listing — it explains the SFTP endpoint and the two
// areas. Written on first materialization of the area (and re-seeded if absent),
// so it self-heals existing members the next time they connect or their page is
// viewed. Members are free to delete or replace it.
//
//go:embed default_readme.txt
var defaultPublicReadme []byte

// Setting keys persisted in files_settings.
const (
	// settingPublicWrite is "members" (default) or "off". When "off", the
	// shared public area is read-only for everyone (operators moderate via the
	// management TUI / filesystem).
	settingPublicWrite = "public_write"
)

// DefaultQuota is the per-user workspace quota when none is configured.
const DefaultQuota int64 = 1 << 30 // 1 GiB

// FilesStore is the slice of the store the Files service needs.
type FilesStore interface {
	UserByFingerprint(fp string) (store.User, bool, error)
	UserByName(name string) (store.User, bool, error)
	ListUsers(limit int) ([]store.User, error)
	FilesAccess(userID int64) (store.FilesAccess, error)
	SetFilesQuota(userID, bytes int64) error
	SetFilesRevoked(userID int64, revoked bool) error
	FilesSetting(key string) (string, bool, error)
	SetFilesSetting(key, value string) error
}

// Config configures the Files service.
type Config struct {
	// Root is the storage root, e.g. <dataDir>/files. The service owns
	// <Root>/users/<name> (private) and <Root>/public (shared).
	Root string
	// DefaultQuota is the per-user workspace quota in bytes (0 → DefaultQuota).
	DefaultQuota int64
}

// Service is the shared Files engine: one instance backs the SFTP subsystem,
// the in-BBS browser, and the operator management TUI.
type Service struct {
	st  FilesStore
	cfg Config
	reg *registry
}

// New builds a Files service and ensures the storage layout exists.
func New(st FilesStore, cfg Config) (*Service, error) {
	if cfg.DefaultQuota <= 0 {
		cfg.DefaultQuota = DefaultQuota
	}
	cfg.Root = filepath.Clean(cfg.Root)
	for _, d := range []string{cfg.Root, filepath.Join(cfg.Root, "users"), filepath.Join(cfg.Root, "public")} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			return nil, err
		}
	}
	return &Service{st: st, cfg: cfg, reg: newRegistry()}, nil
}

// privRoot is the absolute private-workspace directory for a member.
func (s *Service) privRoot(user string) string {
	return filepath.Join(s.cfg.Root, "users", user)
}

// pubRoot is the parent of the per-user public areas (<root>/public). Each
// member's own public files live in a subdirectory keyed by handle; this parent
// is what the operator moderation pane lists.
func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") }

// userPub is a member's own public file area: <root>/public/<name>. It is a
// top-level area in its own right (sibling to the private /me, NOT nested under
// it) and is served anonymously on the web at ~<name>/public.
func (s *Service) userPub(user string) string {
	return filepath.Join(s.pubRoot(), user)
}

// ensureWorkspace creates a member's private workspace if absent.
func (s *Service) ensureWorkspace(user string) error {
	return os.MkdirAll(s.privRoot(user), 0o700)
}

// ensureUserPub creates a member's public area if absent. It is world-readable
// (0o755) because the web host serves it anonymously at ~<name>/public. It also
// seeds a default README.txt when the area has none, so a freshly-provisioned
// (or previously-empty) public listing greets visitors with the SFTP how-to
// instead of "(empty)". Members may delete or overwrite it freely.
func (s *Service) ensureUserPub(user string) error {
	dir := s.userPub(user)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	readme := filepath.Join(dir, "README.txt")
	if _, err := os.Stat(readme); os.IsNotExist(err) {
		// Best-effort: a seed failure must not block file access.
		_ = os.WriteFile(readme, defaultPublicReadme, 0o644)
	}
	return nil
}

// ownedUsage sums a member's two owned areas — their private /me and their
// public /public — for the quota gauge.
func (s *Service) ownedUsage(user string) (int64, error) {
	priv, err := dirSize(s.privRoot(user))
	if err != nil {
		return 0, err
	}
	pub, err := dirSize(s.userPub(user))
	if err != nil {
		return 0, err
	}
	return priv + pub, nil
}

// quotaFor returns the effective quota (bytes) for a user: their per-user
// override if set, else the server default.
func (s *Service) quotaFor(userID int64) int64 {
	if fa, err := s.st.FilesAccess(userID); err == nil && fa.QuotaBytes > 0 {
		return fa.QuotaBytes
	}
	return s.cfg.DefaultQuota
}

// publicWritable reports whether members may write to the shared area. The
// persisted files_settings value wins; default is true (members-only write).
func (s *Service) publicWritable() bool {
	if v, ok, err := s.st.FilesSetting(settingPublicWrite); err == nil && ok {
		return v != "off"
	}
	return true
}

// SetPublicWrite toggles members' write access to the shared public area.
func (s *Service) SetPublicWrite(on bool) error {
	v := "off"
	if on {
		v = "members"
	}
	return s.st.SetFilesSetting(settingPublicWrite, v)
}

// dirSize returns the total bytes used under root (regular files only; symlinks
// are not followed). A missing root counts as 0.
func dirSize(root string) (int64, error) {
	var total int64
	err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error {
		if err != nil {
			if os.IsNotExist(err) {
				return nil
			}
			return err
		}
		if d.Type().IsRegular() {
			info, err := d.Info()
			if err != nil {
				if os.IsNotExist(err) {
					return nil
				}
				return err
			}
			total += info.Size()
		}
		return nil
	})
	if os.IsNotExist(err) {
		return 0, nil
	}
	return total, err
}

// Member is a member listed in the anonymous ~user directory at the root of the
// web file host. PublicBytes is the size of their ~/public folder.
type Member struct {
	Name        string
	PublicBytes int64
}

// Members lists every (non-banned) account, sorted by name — the source for the
// anonymous ~user directory at the root of the web file host. Every member is
// listed (it is a real directory), each with a link to their site and to their
// public files; PublicBytes shows how much they have published.
func (s *Service) Members() ([]Member, error) {
	users, err := s.st.ListUsers(10000)
	if err != nil {
		return nil, err
	}
	out := make([]Member, 0, len(users))
	for _, u := range users {
		if u.Banned {
			continue
		}
		n, _ := dirSize(s.userPub(u.Name))
		out = append(out, Member{Name: u.Name, PublicBytes: n})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out, nil
}

// AnonRoot resolves the on-disk root for an anonymous, read-only browse target:
// a member's own public area /public (name = the ~handle), served at
// ~<name>/public. ok is false when the named member does not exist or is banned.
// The returned root is a confinement boundary — callers must safeJoin onto it,
// and it never exposes the member's private /me.
func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
	if name == "" {
		return "", false, nil
	}
	u, found, err := s.st.UserByName(name)
	if err != nil {
		return "", false, err
	}
	if !found || u.Banned {
		return "", false, nil
	}
	// Materialize the (idempotent) public area so ~name/public is browsable the
	// moment the account exists. Without this, joining onto a missing root trips
	// the escape guard.
	if err := s.ensureUserPub(u.Name); err != nil {
		return "", false, err
	}
	return s.userPub(u.Name), true, nil
}

// SafeJoin exposes the area-confinement join (lexical + symlink-escape guard)
// for the web host's anonymous read-only surface.
func (s *Service) SafeJoin(root, rel string) (string, error) { return safeJoin(root, rel) }

// Usage is a member's workspace usage snapshot.
type Usage struct {
	Bytes int64
	Quota int64
}

// Free reports remaining bytes (never negative).
func (u Usage) Free() int64 {
	if u.Quota <= u.Bytes {
		return 0
	}
	return u.Quota - u.Bytes
}

// Usage computes a member's owned-storage usage (private /me + public /public)
// against their quota.
func (s *Service) Usage(u store.User) (Usage, error) {
	used, err := s.ownedUsage(u.Name)
	if err != nil {
		return Usage{}, err
	}
	return Usage{Bytes: used, Quota: s.quotaFor(u.ID)}, nil
}

// --- live session registry (for the management TUI's Sessions pane) ----------

// Conn is a snapshot of a live SFTP connection, as shown by the management TUI.
type Conn struct {
	ID      int64
	User    string
	Key     string // SSH key fingerprint
	Remote  string
	Started time.Time
	RX      int64 // bytes received from the client (uploads)
	TX      int64 // bytes sent to the client (downloads)
}

// liveConn is the registry's mutable view of an active connection. Its atomic
// counters are updated by countingRWC; snapshots copy out plain Conn values.
type liveConn struct {
	id      int64
	user    string
	key     string
	remote  string
	started time.Time
	rxBytes atomic.Int64
	txBytes atomic.Int64
	closer  func() error
}

type registry struct {
	mu   sync.Mutex
	next int64
	live map[int64]*liveConn
}

func newRegistry() *registry { return &registry{live: map[int64]*liveConn{}} }

func (r *registry) add(user, key, remote string, closer func() error) *liveConn {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.next++
	c := &liveConn{id: r.next, user: user, key: key, remote: remote, started: time.Now(), closer: closer}
	r.live[c.id] = c
	return c
}

func (r *registry) remove(id int64) {
	r.mu.Lock()
	defer r.mu.Unlock()
	delete(r.live, id)
}

func (r *registry) snapshot() []Conn {
	r.mu.Lock()
	defer r.mu.Unlock()
	out := make([]Conn, 0, len(r.live))
	for _, c := range r.live {
		out = append(out, Conn{
			ID: c.id, User: c.user, Key: c.key, Remote: c.remote, Started: c.started,
			RX: c.rxBytes.Load(), TX: c.txBytes.Load(),
		})
	}
	return out
}

// Sessions returns a snapshot of live SFTP connections, newest first.
func (s *Service) Sessions() []Conn {
	cs := s.reg.snapshot()
	for i, j := 0, len(cs)-1; i < j; i, j = i+1, j-1 {
		cs[i], cs[j] = cs[j], cs[i]
	}
	return cs
}

// --- operator / management surface ------------------------------------------

// Users lists accounts for the management TUI (newest first).
func (s *Service) Users() ([]store.User, error) { return s.st.ListUsers(10000) }

// Access returns a user's SFTP access record (quota override + revoked flag).
func (s *Service) Access(userID int64) (store.FilesAccess, error) {
	return s.st.FilesAccess(userID)
}

// SetQuota sets a per-user quota override (bytes; 0 = server default).
func (s *Service) SetQuota(userID, bytes int64) error { return s.st.SetFilesQuota(userID, bytes) }

// SetRevoked revokes/restores a user's SFTP access (BBS login is unaffected).
func (s *Service) SetRevoked(userID int64, revoked bool) error {
	return s.st.SetFilesRevoked(userID, revoked)
}

// PublicWritable reports whether members may currently write to the public area.
func (s *Service) PublicWritable() bool { return s.publicWritable() }

// PublicList lists the top level of the shared public area for moderation.
func (s *Service) PublicList() ([]Entry, error) {
	des, err := os.ReadDir(s.pubRoot())
	if err != nil {
		return nil, err
	}
	out := make([]Entry, 0, len(des))
	for _, de := range des {
		fi, err := de.Info()
		if err != nil {
			continue
		}
		out = append(out, Entry{Name: de.Name(), IsDir: de.IsDir(), Size: fi.Size(), ModTime: fi.ModTime()})
	}
	return out, nil
}

// PublicRemove deletes a top-level entry from the public area (moderation). name
// is treated as a single segment; traversal is rejected.
func (s *Service) PublicRemove(name string) error {
	base := filepath.Base(filepath.Clean("/" + name))
	if base == "." || base == "/" || base == ".." {
		return os.ErrInvalid
	}
	target := filepath.Join(s.pubRoot(), base)
	if !within(s.pubRoot(), target) {
		return os.ErrPermission
	}
	return os.RemoveAll(target)
}

// Kick force-disconnects a live SFTP connection by id. Returns false if unknown.
func (s *Service) Kick(id int64) bool {
	s.reg.mu.Lock()
	c, ok := s.reg.live[id]
	s.reg.mu.Unlock()
	if !ok {
		return false
	}
	if c.closer != nil {
		_ = c.closer()
	}
	return true
}
