// Package stepca wraps the smallstep `step` CLI for the Orca cluster
// CA (REQ-076, D-101 reversing AD-010). The CLI holds the cluster CA's
// private key on the lead node and invokes `step ca init`,
// `step ca certificate`, and `step ca renew` over SSH on the lead via
// the sshpush transport. There is intentionally no Go step-ca client
// library — the zero-new-dependency posture is preserved.
//
// Cert lifetimes follow the SPIFFE/SVID convention: server certs are
// 90-day (2160h) and SVIDs are 24h, matching the v0.9 PRD workload
// identity model (D-068). Renewal happens 30 days before expiry.
package stepca

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"git.cloudinit.dev/coreci/orca/internal/paths"
	"git.cloudinit.dev/coreci/orca/internal/sshpush"
)

// Sentinel errors.
var (
	// ErrLeadUnset is returned when the client has no lead peer
	// configured (e.g., NewClient was given an empty leadPeer).
	ErrLeadUnset = errors.New("stepca: lead peer not set")
	// ErrStepCLI is wrapped around any non-zero exit from the step CLI.
	ErrStepCLI = errors.New("stepca: step CLI failed")
)

// Cert lifetimes (D-068, REQ-076).
const (
	// ServerCertNotAfter is the not-after for peer server certs: 90 days.
	ServerCertNotAfter = "2160h"
	// SVIDNotAfter is the not-after for workload SVIDs: 24 hours.
	SVIDNotAfter = "24h"
	// DefaultProvisioner is the JWE provisioner name the CLI mints
	// tokens against on the lead.
	DefaultProvisioner = "orca-admin"
)

// Client wraps the `step` CLI on the lead node over SSH. The zero
// value is NOT usable; construct one with NewClient.
type Client struct {
	transport *sshpush.Transport
	leadPeer  string
	// exec is the command-execution seam. It defaults to transport
	// when nil (set by NewClient) and is overridden by tests in this
	// package to inject a mock without a real SSH server.
	exec execer
}

// execer is the command-execution interface Client depends on.
// *sshpush.Transport satisfies it via its Exec method. Kept
// unexported so the public API stays keyed to the concrete transport
// (callers pass *sshpush.Transport to NewClient).
type execer interface {
	Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}

// NewClient returns a Client that invokes the step CLI on leadPeer
// (host:port) via transport. A nil transport is rejected at the first
// call site; an empty leadPeer makes every call return ErrLeadUnset.
func NewClient(transport *sshpush.Transport, leadPeer string) *Client {
	return &Client{transport: transport, leadPeer: leadPeer, exec: transport}
}

// run executes cmd on the lead via the exec seam. It is the single
// chokepoint every public method funnels through, so tests intercept
// here.
func (c *Client) run(ctx context.Context, cmd string) ([]byte, error) {
	return c.exec.Exec(ctx, c.leadPeer, cmd)
}

// Init runs `step ca init` on the lead to bootstrap the cluster CA
// (REQ-076). The root cert is expected to land at the location given
// by paths.CACertPath() (the v0.9 cluster/ca.crt location). After the
// init completes, Init copies the root CA cert back to the operator
// host so the CLI can present it to workloads and peers.
func (c *Client) Init(ctx context.Context, name string, dns string, address string) error {
	if err := c.preflight(); err != nil {
		return err
	}
	cmd := fmt.Sprintf(
		"step ca init --name %s --dns %s --address %s --provisioner orca-oidc --deployment-type standalone",
		shellQuote(name), shellQuote(dns), shellQuote(address),
	)
	if _, err := c.run(ctx, cmd); err != nil {
		return fmt.Errorf("stepca: init: %w", err)
	}
	// Mirror the root CA cert to the operator-side paths.CACertPath()
	// so the CLI can hand it out to peers and workloads without a
	// second round-trip. The lead writes it to the canonical step-ca
	// location; we cat it back over SSH.
	remote := "/etc/step-ca/certs/root_ca.crt"
	out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(remote)))
	if err != nil {
		return fmt.Errorf("stepca: read root ca: %w", err)
	}
	if len(out) == 0 {
		return fmt.Errorf("stepca: init produced empty root ca at %s: %w", remote, ErrStepCLI)
	}
	local := paths.CACertPath()
	if mkErr := os.MkdirAll(filepath.Dir(local), 0o755); mkErr != nil {
		return fmt.Errorf("stepca: mkdir %s: %w", filepath.Dir(local), mkErr)
	}
	if wErr := os.WriteFile(local, out, 0o644); wErr != nil {
		return fmt.Errorf("stepca: write %s: %w", local, wErr)
	}
	return nil
}

// IssueServerCert issues a 90-day server cert for peer on the lead via
// `step ca certificate`. The cert and key PEM are returned to the
// caller; the lead-side temp files are unlinked after the read.
// sans are appended as `--san` flags (one per SAN), with peer itself
// always added as the first SAN so the cert is valid for the bare
// hostname.
func (c *Client) IssueServerCert(ctx context.Context, peer string, sans []string) (cert string, key string, err error) {
	if perr := c.preflight(); perr != nil {
		return "", "", perr
	}
	return c.issueCert(ctx, peer, sans, ServerCertNotAfter, "")
}

// IssueSVID issues a 24h workload SVID carrying spiffeID as a URI SAN
// (D-068). The provisioner is pinned to DefaultProvisioner so the
// CLI-side token minting path is exercised consistently.
func (c *Client) IssueSVID(ctx context.Context, spiffeID string, sans []string) (cert string, key string, err error) {
	if perr := c.preflight(); perr != nil {
		return "", "", perr
	}
	return c.issueCert(ctx, spiffeID, sans, SVIDNotAfter, "orca-oidc")
}

// issueCert is the shared helper for IssueServerCert / IssueSVID.
// subject is the cert subject CN (and the first --san). notAfter is
// the duration string passed verbatim to `--not-after`. provisioner,
// when non-empty, is passed as `--provisioner`.
func (c *Client) issueCert(ctx context.Context, subject string, sans []string, notAfter string, provisioner string) (string, string, error) {
	certOut := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.crt", sanitize(subject))
	keyOut := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.key", sanitize(subject))
	var sb strings.Builder
	sb.WriteString("step ca certificate ")
	sb.WriteString(shellQuote(subject))
	sb.WriteString(" ")
	sb.WriteString(shellQuote(certOut))
	sb.WriteString(" ")
	sb.WriteString(shellQuote(keyOut))
	sb.WriteString(" --not-after ")
	sb.WriteString(shellQuote(notAfter))
	sb.WriteString(" --san ")
	sb.WriteString(shellQuote(subject))
	for _, s := range sans {
		sb.WriteString(" --san ")
		sb.WriteString(shellQuote(s))
	}
	if provisioner != "" {
		sb.WriteString(" --provisioner ")
		sb.WriteString(shellQuote(provisioner))
	}
	sb.WriteString(" --force")
	cmd := sb.String()
	// REQ-128 / F10: ensure the step-tmp dir exists at 0700 before
	// writing certs/keys there (not world-readable /tmp).
	if _, err := c.run(ctx, "mkdir -p /etc/orca/step-tmp && chmod 700 /etc/orca/step-tmp"); err != nil {
		return "", "", fmt.Errorf("stepca: mkdir step-tmp: %w", err)
	}
	if _, err := c.run(ctx, cmd); err != nil {
		return "", "", fmt.Errorf("stepca: issue %s: %w", subject, err)
	}
	certPEM, err := c.readFile(ctx, certOut)
	if err != nil {
		return "", "", err
	}
	keyPEM, err := c.readFile(ctx, keyOut)
	if err != nil {
		return "", "", err
	}
	// Best-effort cleanup; failure to unlink is non-fatal.
	_, _ = c.run(ctx, fmt.Sprintf("rm -f %s %s", shellQuote(certOut), shellQuote(keyOut)))
	return certPEM, keyPEM, nil
}

// RenewServerCert renews a peer's server cert 30 days before expiry
// (REQ-076). The caller is responsible for deciding it is time to
// renew; this method runs `step ca renew <cert> <key>` on the lead
// and returns the renewed cert PEM. The key is unchanged by step-ca
// renew for RSA/ECDSA keys; for Ed25519 the key is rotated and the
// new key is returned alongside.
func (c *Client) RenewServerCert(ctx context.Context, peer string) error {
	if perr := c.preflight(); perr != nil {
		return perr
	}
	certPath := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.crt", sanitize(peer))
	keyPath := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.key", sanitize(peer))
	cmd := fmt.Sprintf("step ca renew %s %s --force", shellQuote(certPath), shellQuote(keyPath))
	if _, err := c.run(ctx, cmd); err != nil {
		return fmt.Errorf("stepca: renew %s: %w", peer, err)
	}
	return nil
}

// Fingerprint returns the SHA-256 fingerprint of the cluster root CA
// (paths.CACertPath on the lead, mirrored locally by Init). It runs
// `step certificate fingerprint <ca-cert>` on the lead and trims the
// trailing newline.
func (c *Client) Fingerprint(ctx context.Context) (string, error) {
	if perr := c.preflight(); perr != nil {
		return "", perr
	}
	remote := "/etc/step-ca/certs/root_ca.crt"
	cmd := fmt.Sprintf("step certificate fingerprint %s", shellQuote(remote))
	out, err := c.run(ctx, cmd)
	if err != nil {
		return "", fmt.Errorf("stepca: fingerprint: %w", err)
	}
	fp := strings.TrimSpace(string(out))
	if fp == "" {
		return "", fmt.Errorf("stepca: empty fingerprint: %w", ErrStepCLI)
	}
	return fp, nil
}

// preflight validates the client is usable.
func (c *Client) preflight() error {
	if c.exec == nil {
		return errors.New("stepca: transport is nil")
	}
	if c.leadPeer == "" {
		return ErrLeadUnset
	}
	return nil
}

// readFile cats a lead-side file and returns its contents as a string.
func (c *Client) readFile(ctx context.Context, path string) (string, error) {
	out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(path)))
	if err != nil {
		return "", fmt.Errorf("stepca: read %s: %w", path, err)
	}
	if len(out) == 0 {
		return "", fmt.Errorf("stepca: empty file %s: %w", path, ErrStepCLI)
	}
	return string(out), nil
}

// sanitize replaces path-unsafe characters in a subject so it can be
// used in a /tmp filename. SPIFFE IDs contain `://` and `/`, both of
// which would confuse the shell. We collapse to `_`.
func sanitize(s string) string {
	r := strings.NewReplacer("://", "-", "/", "_", ":", "_", " ", "_")
	return r.Replace(s)
}

// shellQuote single-quotes a string for safe shell interpolation. It
// escapes embedded single-quotes via the standard '\” idiom (mirrors
// sshpush.shellQuote, kept local to avoid importing an unexported
// helper).
func shellQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
