// Package feralssh is a minimal SSH client for the Command Center to run
// read-only inspection commands (ls, find, stat, du) and targeted destructive
// commands (rm) against the Feral seedbox. It is purpose-built for the Tree
// feature: the existing qBit Web API does NOT cover hardlink-aware file
// operations outside qBit's download dir, so the CC needs filesystem access
// to clean up Jellyfin hardlinks during a "delete permanently" action.
//
// The private SSH key is stored in the age-encrypted secrets vault under the
// canonical name returned by KeySecretName(); see provisioning notes in
// docs/SYSTEM.md (added with this feature).
package feralssh

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"strings"
	"sync"
	"time"

	"golang.org/x/crypto/ssh"
)

// KeySecretName is the canonical key under which the Feral SSH private key
// (in OpenSSH PEM form) lives in the age-encrypted secrets store.
const KeySecretName = "feralssh:private_key"

// SecretsAccessor mirrors the narrow Get interface used by other integration
// packages, so feralssh doesn't pull internal/secrets directly.
type SecretsAccessor interface {
	Get(ctx context.Context, key string) ([]byte, error)
}

// Config locates the Feral host and identifies the user account. Exactly one
// of PrivateKeyBytes (operator-managed file on the droplet) or the SecretsAccessor
// passed to New must yield a key — PrivateKeyBytes wins when both are present
// so first-deploy provisioning doesn't depend on a pre-populated vault.
type Config struct {
	Host            string        // e.g. "eurymedon.feralhosting.com:22"
	User            string        // e.g. "feralcovete"
	Timeout         time.Duration
	PrivateKeyBytes []byte        // optional; if set, used directly (vault skipped)
}

// Client is a long-lived SSH connection holder. Construct with New; call from
// any goroutine — internal serialization guards the *ssh.Client lifecycle.
// Methods reconnect once on EOF/closed-channel before failing.
type Client struct {
	cfg     Config
	secrets SecretsAccessor

	mu     sync.Mutex
	conn   *ssh.Client
	signer ssh.Signer
}

// New builds a client. It does NOT dial immediately — the first call to a
// method establishes the SSH session lazily, so a missing key or unreachable
// host doesn't block CC startup; it surfaces at request time as a 502 instead.
func New(cfg Config, secrets SecretsAccessor) (*Client, error) {
	if cfg.Host == "" || cfg.User == "" {
		return nil, errors.New("feralssh: Host and User are required")
	}
	if cfg.Timeout == 0 {
		cfg.Timeout = 15 * time.Second
	}
	if !strings.Contains(cfg.Host, ":") {
		cfg.Host = cfg.Host + ":22"
	}
	return &Client{cfg: cfg, secrets: secrets}, nil
}

// Close releases the cached SSH connection. Safe to call multiple times.
func (c *Client) Close() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn != nil {
		err := c.conn.Close()
		c.conn = nil
		return err
	}
	return nil
}

// Run executes a single command and returns stdout. Stderr is folded into the
// returned error on non-zero exit so the caller sees the actual reason.
func (c *Client) Run(ctx context.Context, cmd string) (string, error) {
	out, err := c.run(ctx, cmd)
	if err == nil {
		return out, nil
	}
	// One reconnect attempt on transport-level failures (EOF, closed channel).
	if isTransport(err) {
		c.reset()
		out, err = c.run(ctx, cmd)
	}
	return out, err
}

func (c *Client) run(ctx context.Context, cmd string) (string, error) {
	conn, err := c.dial(ctx)
	if err != nil {
		return "", err
	}
	sess, err := conn.NewSession()
	if err != nil {
		return "", fmt.Errorf("feralssh: new session: %w", err)
	}
	defer sess.Close()

	var stdout, stderr bytes.Buffer
	sess.Stdout = &stdout
	sess.Stderr = &stderr

	done := make(chan error, 1)
	go func() { done <- sess.Run(cmd) }()
	select {
	case <-ctx.Done():
		_ = sess.Signal(ssh.SIGTERM)
		return "", ctx.Err()
	case err := <-done:
		if err != nil {
			errText := strings.TrimSpace(stderr.String())
			if errText == "" {
				errText = err.Error()
			}
			return stdout.String(), fmt.Errorf("feralssh: %q failed: %s", trunc(cmd, 120), errText)
		}
	}
	return stdout.String(), nil
}

// dial returns the cached *ssh.Client, opening it on demand. Lazy because the
// signer requires a secrets store fetch (decryption) — we don't want to do
// that at CC boot if Feral is unreachable.
func (c *Client) dial(ctx context.Context) (*ssh.Client, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn != nil {
		return c.conn, nil
	}
	if c.signer == nil {
		var keyBytes []byte
		switch {
		case len(c.cfg.PrivateKeyBytes) > 0:
			keyBytes = c.cfg.PrivateKeyBytes
		case c.secrets != nil:
			b, err := c.secrets.Get(ctx, KeySecretName)
			if err != nil {
				return nil, fmt.Errorf("feralssh: load %s from secrets: %w", KeySecretName, err)
			}
			keyBytes = b
		default:
			return nil, errors.New("feralssh: no key source — set Config.PrivateKeyBytes or wire a secrets store")
		}
		sgn, err := ssh.ParsePrivateKey(keyBytes)
		if err != nil {
			return nil, fmt.Errorf("feralssh: parse private key: %w", err)
		}
		c.signer = sgn
	}
	cfg := &ssh.ClientConfig{
		User:            c.cfg.User,
		Auth:            []ssh.AuthMethod{ssh.PublicKeys(c.signer)},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Feral is a known peer; no host-key pin yet
		Timeout:         c.cfg.Timeout,
	}
	conn, err := ssh.Dial("tcp", c.cfg.Host, cfg)
	if err != nil {
		return nil, fmt.Errorf("feralssh: dial %s: %w", c.cfg.Host, err)
	}
	c.conn = conn
	return conn, nil
}

func (c *Client) reset() {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn != nil {
		_ = c.conn.Close()
		c.conn = nil
	}
}

func isTransport(err error) bool {
	if err == nil {
		return false
	}
	s := err.Error()
	return strings.Contains(s, "EOF") ||
		strings.Contains(s, "closed network connection") ||
		strings.Contains(s, "channel closed") ||
		strings.Contains(s, "i/o timeout")
}

func trunc(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "..."
}

// ---------------------------------------------------------------------------
// Higher-level convenience methods used by routes_tree.go. Each runs ONE
// command via Run() and parses a simple text format.

// FileEntry is one ls row.
type FileEntry struct {
	Name     string `json:"name"`
	FullPath string `json:"full_path"`
	IsDir    bool   `json:"is_dir"`
	Size     int64  `json:"size_bytes"`
	Inode    int64  `json:"inode,omitempty"`
	Links    int    `json:"link_count,omitempty"`
}

// ListDir returns the entries inside path (one level, not recursive). Symlinks
// followed (`-L`) so the user sees the target dir/file shape, which matches
// what Jellyfin/the user perceives in their tree.
func (c *Client) ListDir(ctx context.Context, path string) ([]FileEntry, error) {
	// Format: <inode> <links> <bytes> <type>\t<name>
	// Use printf-style stat per entry — `find -maxdepth 1 -mindepth 1` then
	// stat each. This is one round-trip and avoids parsing `ls -l`.
	cmd := fmt.Sprintf(
		`find %s -mindepth 1 -maxdepth 1 -printf '%%i\t%%n\t%%s\t%%y\t%%f\n' 2>/dev/null | sort -k5`,
		shellPath(path),
	)
	out, err := c.Run(ctx, cmd)
	if err != nil {
		return nil, err
	}
	// Always return a non-nil slice so JSON marshalling produces [] not null.
	entries := []FileEntry{}
	for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
		if line == "" {
			continue
		}
		parts := strings.SplitN(line, "\t", 5)
		if len(parts) != 5 {
			continue
		}
		ino := atoi64(parts[0])
		lnk := atoi(parts[1])
		sz := atoi64(parts[2])
		typeChar := parts[3]
		name := parts[4]
		entries = append(entries, FileEntry{
			Name:     name,
			FullPath: strings.TrimRight(path, "/") + "/" + name,
			IsDir:    typeChar == "d",
			Size:     sz,
			Inode:    ino,
			Links:    lnk,
		})
	}
	return entries, nil
}

// FindByInode returns every path under the user's home (and ~/media, since
// it's the Jellyfin keepers root) that shares the given inode. Used to surface
// hardlinks before a delete so the operator knows the full blast radius.
func (c *Client) FindByInode(ctx context.Context, inode int64) ([]string, error) {
	results, err := c.FindByInodes(ctx, []int64{inode})
	if err != nil {
		return nil, err
	}
	return results[inode], nil
}

// FindByInodes runs ONE find call across $HOME and returns a map of inode → paths.
// Batching multiple inodes into a single -inum-OR predicate set is the dominant
// perf win for /api/tree/inspect (the walk is the cost, not the matching), per
// the wo6dgbpvu workflow timings (8 inodes one-by-one: ~240ms; batched: ~32ms).
func (c *Client) FindByInodes(ctx context.Context, inodes []int64) (map[int64][]string, error) {
	out := map[int64][]string{}
	if len(inodes) == 0 {
		return out, nil
	}
	// Build `\( -inum N1 -o -inum N2 -o ... \)` so a single walk satisfies them all.
	var b strings.Builder
	b.WriteString(`( `)
	for i, ino := range inodes {
		if i > 0 {
			b.WriteString(" -o ")
		}
		b.WriteString(fmt.Sprintf("-inum %d", ino))
		out[ino] = nil
	}
	b.WriteString(` )`)
	// `-printf '%i\t%p\n'` per match so we can group results by inode in one pass.
	// Keep scope at $HOME (refuter wo6dgbpvu refute-fix #1 — narrowing to
	// ~/private+~/media silently misses operator-custom keeper dirs like
	// ~/audiobookshelf, ~/keepers, etc., breaking the cascade-delete preview).
	// Skip .cache to dodge known junk.
	cmd := fmt.Sprintf(
		`find "$HOME" -mount -path "$HOME/.cache" -prune -o -type f %s -printf '%%i\t%%p\n' 2>/dev/null`,
		b.String(),
	)
	raw, err := c.Run(ctx, cmd)
	if err != nil {
		return nil, err
	}
	for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
		if line == "" {
			continue
		}
		parts := strings.SplitN(line, "\t", 2)
		if len(parts) != 2 {
			continue
		}
		var ino int64
		_, _ = fmt.Sscanf(parts[0], "%d", &ino)
		out[ino] = append(out[ino], parts[1])
	}
	return out, nil
}

// FindByName locates a file or directory by basename under any of the given
// roots, returning the first match (depth-first, one find call). Used when
// qBit's reported content_path doesn't stat on the host (Feral's qBit runs in
// a container with a different filesystem view, so qBit may report e.g.
// /data/foo when the actual path on the host is ~/private/qbittorrent/data/foo).
func (c *Client) FindByName(ctx context.Context, name string, roots []string) (string, error) {
	if name == "" || len(roots) == 0 {
		return "", nil
	}
	quoted := make([]string, 0, len(roots))
	for _, r := range roots {
		quoted = append(quoted, shellPath(r))
	}
	cmd := fmt.Sprintf(
		`find %s -name %s -print -quit 2>/dev/null`,
		strings.Join(quoted, " "),
		shellQuote(name),
	)
	out, err := c.Run(ctx, cmd)
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(out), nil
}

// PathStat is a one-shot stat that returns the fields the Tree feature needs.
type PathStat struct {
	Inode    int64
	Links    int
	Size     int64
	IsDir    bool
	Exists   bool
}

// Stat returns inode, link count, size, and type for path. Missing path is
// reported as {Exists:false}, not an error.
func (c *Client) Stat(ctx context.Context, path string) (PathStat, error) {
	// IMPORTANT: use `stat --printf` (NOT `stat -c`). GNU `stat -c` treats
	// `\t` as the literal two characters '\' and 't'; `--printf` interprets
	// escapes. Without this, the parser sees one giant token and bails with
	// "unexpected output" even when the file exists. (`find -printf` always
	// interprets escapes, which is why ListDir/FindByInode worked.)
	cmd := fmt.Sprintf(`stat --printf '%%i\t%%h\t%%s\t%%F' %s 2>/dev/null`, shellPath(path))
	out, err := c.Run(ctx, cmd)
	if err != nil {
		// stat exits 1 when the file doesn't exist; surface as not-found.
		if strings.Contains(err.Error(), "exited") || strings.Contains(err.Error(), "No such") {
			return PathStat{Exists: false}, nil
		}
		return PathStat{}, err
	}
	out = strings.TrimSpace(out)
	if out == "" {
		return PathStat{Exists: false}, nil
	}
	parts := strings.SplitN(out, "\t", 4)
	if len(parts) != 4 {
		return PathStat{}, fmt.Errorf("feralssh: stat: unexpected output: %q", out)
	}
	return PathStat{
		Inode:  atoi64(parts[0]),
		Links:  atoi(parts[1]),
		Size:   atoi64(parts[2]),
		IsDir:  strings.Contains(parts[3], "directory"),
		Exists: true,
	}, nil
}

// DiskUsage returns the total apparent bytes used by path (recursive). Uses
// `du -sb` so the number matches what a `rm -rf` would actually free of the
// file content (apparent size, not block size).
func (c *Client) DiskUsage(ctx context.Context, path string) (int64, error) {
	cmd := fmt.Sprintf(`du -sb %s 2>/dev/null | awk '{print $1}'`, shellPath(path))
	out, err := c.Run(ctx, cmd)
	if err != nil {
		return 0, err
	}
	return atoi64(strings.TrimSpace(out)), nil
}

// Remove is the destructive primitive: `rm -rf "path"`. The caller is
// responsible for validating that path is inside the seedbox user's allowed
// roots — see routes_tree.go's pathAllowed guard.
func (c *Client) Remove(ctx context.Context, path string) error {
	_, err := c.Run(ctx, fmt.Sprintf(`rm -rf %s`, shellPath(path)))
	return err
}

// shellQuote wraps s in single quotes and escapes embedded single quotes —
// strict literal mode for arguments that must NOT be subject to shell
// expansion (filenames, patterns, basenames).
func shellQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

// shellPath quotes a filesystem path. `~` and `~/...` are rewritten to
// `$HOME` / `$HOME/...` and then double-quoted so bash expands the variable;
// other paths get strict single-quote literal treatment. Without this, single-
// quoting `~` makes the shell look for a directory literally named `~` and
// every ListDir/Stat/Remove of the user's home reports "empty/missing."
func shellPath(s string) string {
	if s == "~" {
		s = "$HOME"
	} else if strings.HasPrefix(s, "~/") {
		s = "$HOME" + s[1:]
	}
	if strings.HasPrefix(s, "$") {
		// Wrap in double quotes; replace any literal `"` for safety even
		// though our paths shouldn't contain it.
		return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
	}
	return shellQuote(s)
}

func atoi(s string) int {
	var n int
	_, _ = fmt.Sscanf(s, "%d", &n)
	return n
}
func atoi64(s string) int64 {
	var n int64
	_, _ = fmt.Sscanf(s, "%d", &n)
	return n
}

