package actuator

import (
	"bytes"
	"context"
	"fmt"
	"net"
	"os"
	"strings"
	"time"

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

// defaultDialTimeout bounds an SSH dial when the caller leaves Timeout unset.
// 10s matches the previous hardcoded value at every dial site.
const defaultDialTimeout = 10 * time.Second

// LoadSigner reads and parses the private key at keyPath.
func LoadSigner(keyPath string) (ssh.Signer, error) {
	key, err := os.ReadFile(keyPath)
	if err != nil {
		return nil, fmt.Errorf("read ssh key: %w", err)
	}
	return LoadSignerFromBytes(key)
}

// LoadSignerFromBytes parses an in-memory private key into an ssh.Signer.
func LoadSignerFromBytes(key []byte) (ssh.Signer, error) {
	signer, err := ssh.ParsePrivateKey(key)
	if err != nil {
		return nil, fmt.Errorf("parse ssh key: %w", err)
	}
	return signer, nil
}

// DialOptions configures an SSH dial.
type DialOptions struct {
	Host    string
	Port    int           // 0 means 22
	User    string
	Signer  ssh.Signer
	Timeout time.Duration // dial timeout; <=0 means defaultDialTimeout
}

// Dial opens a crypto/ssh connection through the centralized HostKeyCallback.
// The connection itself is bounded by Timeout; ctx is respected by callers
// via RunCombinedOutput once the session is running.
func Dial(ctx context.Context, opts DialOptions) (*ssh.Client, error) {
	port := opts.Port
	if port <= 0 {
		port = 22
	}
	timeout := opts.Timeout
	if timeout <= 0 {
		timeout = defaultDialTimeout
	}
	cfg := &ssh.ClientConfig{
		User:            opts.User,
		Auth:            []ssh.AuthMethod{ssh.PublicKeys(opts.Signer)},
		HostKeyCallback: HostKeyCallback(),
		Timeout:         timeout,
	}
	addr := net.JoinHostPort(opts.Host, fmt.Sprintf("%d", port))
	client, err := ssh.Dial("tcp", addr, cfg)
	if err != nil {
		return nil, fmt.Errorf("ssh dial %s:%d: %w", opts.Host, port, err)
	}
	return client, nil
}

// RunCombinedOutput runs cmd on an established client and returns its combined
// stdout/stderr. Context cancellation closes the session to abort the remote
// command instead of blocking until it finishes — the same goroutine+select
// pattern the actuator, mcp, and scheduler each reimplemented before.
func RunCombinedOutput(ctx context.Context, client *ssh.Client, cmd string) ([]byte, error) {
	session, err := client.NewSession()
	if err != nil {
		return nil, fmt.Errorf("create session: %w", err)
	}
	defer session.Close()

	type result struct {
		out []byte
		err error
	}
	ch := make(chan result, 1)
	go func() {
		out, err := session.CombinedOutput(cmd)
		ch <- result{out: out, err: err}
	}()

	select {
	case <-ctx.Done():
		session.Close()
		return nil, ctx.Err()
	case res := <-ch:
		if res.err != nil {
			return res.out, fmt.Errorf("command: %w", res.err)
		}
		return res.out, nil
	}
}

// RunOutput runs cmd on an established client and returns stdout only.
// Stderr is folded into the returned error so callers that parse stdout
// as JSON (e.g. the scheduler's check scripts) don't get interleaved
// stderr in the output stream.
func RunOutput(ctx context.Context, client *ssh.Client, cmd string) ([]byte, error) {
	session, err := client.NewSession()
	if err != nil {
		return nil, fmt.Errorf("create session: %w", err)
	}
	defer session.Close()

	var outBuf, errBuf bytes.Buffer
	session.Stdout = &outBuf
	session.Stderr = &errBuf

	type result struct {
		runErr error
	}
	ch := make(chan result, 1)
	go func() {
		ch <- result{runErr: session.Run(cmd)}
	}()

	select {
	case <-ctx.Done():
		session.Close()
		return nil, ctx.Err()
	case res := <-ch:
		if res.runErr != nil {
			if errBuf.Len() > 0 {
				return outBuf.Bytes(), fmt.Errorf("command: %w\nstderr: %s", res.runErr, strings.TrimSpace(errBuf.String()))
			}
			return outBuf.Bytes(), fmt.Errorf("command: %w", res.runErr)
		}
		return outBuf.Bytes(), nil
	}
}