package offsiteapply

import (
	"context"
	"crypto/ed25519"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"

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

// --- func adapters (convenient wiring in main.go) ---

type ConsumerFunc func(ctx context.Context) (string, error)

func (f ConsumerFunc) Consume(ctx context.Context) (string, error) { return f(ctx) }

type EnablerFunc func(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error

func (f EnablerFunc) ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error {
	return f(ctx, host, user, port, repoPath, privPEM, knownHosts)
}

// --- HTTPConsumer: POST the hub consume-password endpoint with the per-customer API key ---

type HTTPConsumer struct {
	HubURL     string
	CustomerID string
	APIKey     string
	HC         *http.Client
}

func (c HTTPConsumer) Consume(ctx context.Context) (string, error) {
	if c.HubURL == "" || c.CustomerID == "" || c.APIKey == "" {
		return "", fmt.Errorf("offsite-apply: consume: hub url/customer/apikey not configured")
	}
	hc := c.HC
	if hc == nil {
		hc = &http.Client{Timeout: 20 * time.Second}
	}
	url := strings.TrimRight(c.HubURL, "/") + "/api/v1/offsite/consume-password/" + c.CustomerID
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("Authorization", "Bearer "+c.APIKey)
	resp, err := hc.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
	if resp.StatusCode == http.StatusNotFound {
		return "", fmt.Errorf("no unconsumed offsite password (already consumed or none provisioned)")
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return "", fmt.Errorf("consume: HTTP %d", resp.StatusCode)
	}
	var body struct {
		Password string `json:"password"`
	}
	if err := json.Unmarshal(raw, &body); err != nil || body.Password == "" {
		return "", fmt.Errorf("consume: malformed response")
	}
	return body.Password, nil // NEVER logged
}

// --- KeyscanScanner: capture the box host key (x/crypto/ssh, no binary) → fingerprint + known_hosts line ---

type KeyscanScanner struct {
	Timeout time.Duration
}

var errScanCaptured = errors.New("host key captured")

func (s KeyscanScanner) Scan(ctx context.Context, host string, port int) (string, string, error) {
	timeout := s.Timeout
	if timeout == 0 {
		timeout = 10 * time.Second
	}
	var fp, line string
	cfg := &ssh.ClientConfig{
		User:    "felhom-keyscan",
		Timeout: timeout,
		HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
			fp = ssh.FingerprintSHA256(key)
			line = knownhosts.Line([]string{knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))}, key)
			return errScanCaptured
		},
	}
	d := net.Dialer{Timeout: timeout}
	conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
	if err != nil {
		return "", "", fmt.Errorf("dial: %w", err)
	}
	defer conn.Close()
	c, _, _, herr := ssh.NewClientConn(conn, host, cfg)
	if c != nil {
		c.Close()
	}
	if fp != "" && line != "" {
		return fp, line, nil
	}
	return "", "", fmt.Errorf("host-key handshake: %w", herr)
}

// --- ED25519KeyGen: a fresh keypair (OpenSSH private PEM + authorized_keys pub line) ---

type ED25519KeyGen struct{}

func (ED25519KeyGen) Generate() (string, string, error) {
	pub, priv, err := ed25519.GenerateKey(nil)
	if err != nil {
		return "", "", err
	}
	block, err := ssh.MarshalPrivateKey(priv, "felhom-offbox")
	if err != nil {
		return "", "", err
	}
	sshPub, err := ssh.NewPublicKey(pub)
	if err != nil {
		return "", "", err
	}
	privPEM := string(pem.EncodeToMemory(block))
	pubLine := string(ssh.MarshalAuthorizedKey(sshPub)) // includes trailing newline
	return privPEM, pubLine, nil
}

// --- SSHCopyIDInstaller: install the pubkey via the proven `sshpass -e ssh-copy-id -p N -s -f`, verify ---

type SSHCopyIDInstaller struct{}

func (SSHCopyIDInstaller) Install(ctx context.Context, host, user string, port int, password, privPEM, pubAuthorized, knownHosts string) error {
	if strings.TrimSpace(knownHosts) == "" {
		return fmt.Errorf("ssh-copy-id: empty known_hosts — refusing to install without a pinned host key")
	}
	work, err := os.MkdirTemp("", "felhom-keyinstall-")
	if err != nil {
		return err
	}
	defer os.RemoveAll(work)
	pubPath := filepath.Join(work, "id.pub")
	privPath := filepath.Join(work, "id")
	khPath := filepath.Join(work, "known_hosts")
	if err := os.WriteFile(pubPath, []byte(pubAuthorized), 0o600); err != nil {
		return err
	}
	if err := os.WriteFile(privPath, []byte(privPEM), 0o600); err != nil {
		return err
	}
	// Pin the scanner-VERIFIED host key: StrictHostKeyChecking=yes against this known_hosts refuses any
	// other key (no accept-new/TOFU) — the ssh-copy-id + verify sessions connect ONLY to the box whose
	// fingerprint the bridge already matched against the hub descriptor.
	if err := os.WriteFile(khPath, []byte(knownHosts+"\n"), 0o600); err != nil {
		return err
	}
	// Install (SSHPASS env is read by `sshpass -e`; the password never appears on argv).
	install := exec.CommandContext(ctx, "sshpass", "-e", "ssh-copy-id", "-p", strconv.Itoa(port), "-s", "-f",
		"-i", pubPath, "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile="+khPath, user+"@"+host)
	install.Env = append(os.Environ(), "SSHPASS="+password)
	if out, err := install.CombinedOutput(); err != nil {
		return fmt.Errorf("ssh-copy-id: %w: %s", err, truncate(out))
	}
	// Verify passwordless key auth (an SFTP no-op; the box's restricted shell only offers SFTP).
	verify := exec.CommandContext(ctx, "sftp", "-b", "-", "-P", strconv.Itoa(port),
		"-i", privPath, "-oBatchMode=yes", "-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile="+khPath, user+"@"+host)
	verify.Stdin = strings.NewReader("pwd\n")
	if out, err := verify.CombinedOutput(); err != nil {
		return fmt.Errorf("key-auth verify failed after install: %w: %s", err, truncate(out))
	}
	return nil
}

func truncate(b []byte) string {
	s := strings.TrimSpace(string(b))
	if len(s) > 300 {
		return s[:300] + "…"
	}
	return s
}
