// kerberos_uploader.go — an SSHUploader implementation that delegates to
// the system /usr/bin/ssh + /usr/bin/scp binaries so an existing Kerberos
// (GSSAPI) credential cache populated by `kinit` is reused transparently.
//
// 设计理由 (为什么不用 golang.org/x/crypto/ssh 做 Kerberos):
//   - macOS 的 Kerberos 票据默认存放在 API: 类型的 credential cache 中,
//     由系统 GSSD (Heimdal) 守护进程托管. Go 生态的 gokrb5 只能读取
//     FILE: 类型 ccache 与 keytab, 无法访问 API: ccache, 因此在 macOS 上
//     用纯 Go 实现 GSSAPI 认证基本不可行.
//   - 系统自带的 /usr/bin/ssh 原生集成 Heimdal GSSAPI, 用户 `kinit` 之后
//     的票据可被直接复用. 把上传委托给系统 ssh/scp 是最稳妥的方案, 也与
//     用户"命令行能免密登录即可"的预期完全一致.
//   - 仅在 AuthMethod == kerberos 时启用此实现; builtin 路径不受影响.
package ssh

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path"
	"strconv"
	"strings"
	"time"

	"github.com/mmmy/snapgo/internal/domain"
)

// systemSSHPath / systemSCPPath are the absolute paths to the macOS system
// binaries. We use absolute paths rather than relying on $PATH so a hijacked
// PATH cannot redirect us to an arbitrary binary, and because GUI apps on
// macOS often launch with a minimal PATH that omits /usr/bin entirely.
const (
	systemSSHPath = "/usr/bin/ssh"
	systemSCPPath = "/usr/bin/scp"
)

// KerberosUploader satisfies application.SSHUploader by shelling out to the
// system scp binary with GSSAPI authentication enabled.
type KerberosUploader struct {
	cfg domain.SSHConfig
}

// NewKerberosUploader returns an uploader that delegates to system scp.
func NewKerberosUploader(cfg domain.SSHConfig) *KerberosUploader {
	return &KerberosUploader{cfg: cfg}
}

// Upload writes data to remoteRelPath (relative to the remote $HOME) by
// staging it in a local temp file and invoking system scp once.
//
// We stage to a temp file because scp transfers files, not stdin; piping a
// stream would require the more fragile `scp -t` sink protocol that we only
// use for the in-process client. A temp file is simpler and robust.
func (u *KerberosUploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
	start := time.Now()
	cleaned := normaliseRemotePath(remoteRelPath)
	if cleaned == "" {
		return fmt.Errorf("ssh(kerberos): remote path is empty")
	}
	sshLog().Info("kerberos uploader start",
		"host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
		"remote_path", cleaned, "size", len(data))

	// 1. Ensure the remote directory tree exists. scp itself will not create
	//    intermediate directories, so we run a remote `mkdir -p` first.
	dir, _ := path.Split(cleaned)
	dir = strings.Trim(dir, "/")
	if dir != "" {
		if err := u.runRemoteMkdir(ctx, dir); err != nil {
			sshLog().Error("kerberos uploader mkdir failed",
				"dir", dir, "elapsed", time.Since(start), "err", err)
			return err
		}
	}

	// 2. Stage the payload to a local temp file for scp to read.
	tmp, err := os.CreateTemp("", "snapgo-scp-*.png")
	if err != nil {
		return fmt.Errorf("ssh(kerberos): create temp: %w", err)
	}
	tmpPath := tmp.Name()
	defer os.Remove(tmpPath)
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("ssh(kerberos): write temp: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("ssh(kerberos): close temp: %w", err)
	}
	if err := os.Chmod(tmpPath, mode.Perm()); err != nil {
		sshLog().Debug("kerberos uploader chmod temp failed", "err", err)
	}

	// 3. scp the temp file to "user@host:cleaned" (cleaned is relative to
	//    $HOME, which scp interprets correctly for a remote login shell).
	remoteTarget := fmt.Sprintf("%s@%s:%s", u.cfg.User, u.cfg.Host, cleaned)
	args := append(u.commonSCPArgs(), tmpPath, remoteTarget)
	if err := u.runCommand(ctx, systemSCPPath, args); err != nil {
		sshLog().Error("kerberos uploader scp failed",
			"remote_path", cleaned, "elapsed", time.Since(start), "err", err)
		return err
	}
	sshLog().Info("kerberos uploader done",
		"remote_path", cleaned, "size", len(data), "total_elapsed", time.Since(start))
	return nil
}

// runRemoteMkdir runs `mkdir -p ~/<dir>` on the remote host via system ssh.
func (u *KerberosUploader) runRemoteMkdir(ctx context.Context, dir string) error {
	remote := fmt.Sprintf("%s@%s", u.cfg.User, u.cfg.Host)
	// Quote the directory so spaces / metacharacters cannot break the shell
	// command. dir is already normalised (no leading ~ or /).
	cmd := "mkdir -p " + shellQuote("./"+dir)
	args := append(u.commonSSHArgs(), remote, cmd)
	return u.runCommand(ctx, systemSSHPath, args)
}

// commonSSHArgs builds the shared option set that enables GSSAPI auth and
// disables interactive prompts (we want a hard failure, never a hang waiting
// for a password the GUI cannot answer).
func (u *KerberosUploader) commonSSHArgs() []string {
	port := u.cfg.Port
	if port <= 0 {
		port = 22
	}
	timeout := u.cfg.ConnectTimeoutSecs
	if timeout <= 0 {
		timeout = 10
	}
	args := []string{
		"-p", strconv.Itoa(port),
		"-o", "GSSAPIAuthentication=yes",
		"-o", "GSSAPIDelegateCredentials=no",
		"-o", "PreferredAuthentications=gssapi-with-mic,gssapi-keyex",
		"-o", "PubkeyAuthentication=no",
		"-o", "PasswordAuthentication=no",
		"-o", "KbdInteractiveAuthentication=no",
		"-o", "BatchMode=yes",
		"-o", "ConnectTimeout=" + strconv.Itoa(timeout),
	}
	args = append(args, u.hostKeyArgs()...)
	return args
}

// commonSCPArgs mirrors commonSSHArgs but uses scp's uppercase -P port flag.
func (u *KerberosUploader) commonSCPArgs() []string {
	port := u.cfg.Port
	if port <= 0 {
		port = 22
	}
	timeout := u.cfg.ConnectTimeoutSecs
	if timeout <= 0 {
		timeout = 10
	}
	args := []string{
		"-P", strconv.Itoa(port),
		"-o", "GSSAPIAuthentication=yes",
		"-o", "GSSAPIDelegateCredentials=no",
		"-o", "PreferredAuthentications=gssapi-with-mic,gssapi-keyex",
		"-o", "PubkeyAuthentication=no",
		"-o", "PasswordAuthentication=no",
		"-o", "KbdInteractiveAuthentication=no",
		"-o", "BatchMode=yes",
		"-o", "ConnectTimeout=" + strconv.Itoa(timeout),
	}
	args = append(args, u.hostKeyArgs()...)
	return args
}

// hostKeyArgs maps StrictHostKey onto OpenSSH's StrictHostKeyChecking option,
// keeping behaviour consistent with the in-process client.
func (u *KerberosUploader) hostKeyArgs() []string {
	if u.cfg.StrictHostKey {
		if u.cfg.KnownHostsPath != "" {
			return []string{
				"-o", "StrictHostKeyChecking=yes",
				"-o", "UserKnownHostsFile=" + u.cfg.KnownHostsPath,
			}
		}
		return []string{"-o", "StrictHostKeyChecking=yes"}
	}
	// Non-strict: accept new keys automatically but still record them. This
	// mirrors the in-process InsecureIgnoreHostKey trade-off the UI warns of.
	return []string{"-o", "StrictHostKeyChecking=accept-new"}
}

// runCommand executes the given binary, capturing combined stderr so a
// failure surfaces the remote/OpenSSH diagnostic instead of a bare exit code.
func (u *KerberosUploader) runCommand(ctx context.Context, bin string, args []string) error {
	cmd := exec.CommandContext(ctx, bin, args...)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	sshLog().Debug("kerberos exec", "bin", bin, "args", strings.Join(args, " "))
	if err := cmd.Run(); err != nil {
		msg := strings.TrimSpace(stderr.String())
		if msg == "" {
			msg = err.Error()
		}
		return fmt.Errorf("%s failed: %s", path.Base(bin), msg)
	}
	return nil
}

// TestKerberosConnection probes the remote host with system ssh, surfacing a
// clear hint when no valid ticket is present.
func TestKerberosConnection(ctx context.Context, cfg domain.SSHConfig) error {
	sshLog().Info("kerberos test connection start",
		"host", cfg.Host, "user", cfg.User, "port", cfg.Port)
	u := &KerberosUploader{cfg: cfg}
	remote := fmt.Sprintf("%s@%s", cfg.User, cfg.Host)
	args := append(u.commonSSHArgs(), remote, "true")
	if err := u.runCommand(ctx, systemSSHPath, args); err != nil {
		// Detect the most common cause: no/expired Kerberos ticket.
		if !hasKerberosTicket(ctx) {
			return fmt.Errorf("no valid Kerberos ticket found — run `kinit %s@BYTEDANCE.COM` first (%w)", cfg.User, err)
		}
		return err
	}
	sshLog().Info("kerberos test connection ok")
	return nil
}

// hasKerberosTicket reports whether `klist -s` indicates a valid ticket.
// `klist -s` exits 0 when a valid TGT exists, non-zero otherwise.
func hasKerberosTicket(ctx context.Context) bool {
	cmd := exec.CommandContext(ctx, "/usr/bin/klist", "-s")
	return cmd.Run() == nil
}
