package stepca

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

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

// mockExec is a record-and-replay execer for the stepca.Client. It
// stores every command it received keyed by a substring match, so a
// test can assert "Init ran `step ca init`" without coupling to
// exact-flag ordering. Each entry maps a substring the test expects
// to appear in the command to the output that should be returned.
type mockExec struct {
	// responses is a list of (substring, output, err). The first
	// matching entry wins; an entry with an empty substring matches
	// any command (catch-all).
	responses []mockResp
	// calls records every command the client issued, in order.
	calls []string
}

type mockResp struct {
	match string
	out   []byte
	err   error
}

func (m *mockExec) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
	m.calls = append(m.calls, cmd)
	for _, r := range m.responses {
		if r.match == "" || strings.Contains(cmd, r.match) {
			return r.out, r.err
		}
	}
	return nil, nil
}

// newMockClient returns a Client wired to a mockExec and an ORCA_HOME
// under a temp dir (so paths.CACertPath() resolves to a writable path
// during Init tests).
func newMockClient(t *testing.T, lead string) (*Client, *mockExec) {
	t.Helper()
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	mx := &mockExec{}
	c := NewClient(nil, lead)
	c.exec = mx
	return c, mx
}

func containsCall(t *testing.T, mx *mockExec, want string) {
	t.Helper()
	for _, c := range mx.calls {
		if strings.Contains(c, want) {
			return
		}
	}
	t.Errorf("no exec call contained %q; calls were:\n%s", want, strings.Join(mx.calls, "\n"))
}

func TestNewClient_Defaults(t *testing.T) {
	tr := sshpush.NewTransport("/tmp/key", "/tmp/kh")
	c := NewClient(tr, "lead:22")
	if c.leadPeer != "lead:22" {
		t.Errorf("leadPeer = %q", c.leadPeer)
	}
	if c.transport != tr {
		t.Error("transport not stored")
	}
	if c.exec == nil {
		t.Error("exec seam is nil")
	}
}

func TestClient_Preflight_LeadUnset(t *testing.T) {
	c, _ := newMockClient(t, "")
	if err := c.Init(context.Background(), "n", "d", "a"); !errors.Is(err, ErrLeadUnset) {
		t.Errorf("Init with empty lead: err = %v, want ErrLeadUnset", err)
	}
	if _, _, err := c.IssueServerCert(context.Background(), "p", nil); !errors.Is(err, ErrLeadUnset) {
		t.Errorf("IssueServerCert: err = %v, want ErrLeadUnset", err)
	}
	if _, _, err := c.IssueSVID(context.Background(), "spiffe://orca/x", nil); !errors.Is(err, ErrLeadUnset) {
		t.Errorf("IssueSVID: err = %v, want ErrLeadUnset", err)
	}
	if err := c.RenewServerCert(context.Background(), "p"); !errors.Is(err, ErrLeadUnset) {
		t.Errorf("RenewServerCert: err = %v, want ErrLeadUnset", err)
	}
	if _, err := c.Fingerprint(context.Background()); !errors.Is(err, ErrLeadUnset) {
		t.Errorf("Fingerprint: err = %v, want ErrLeadUnset", err)
	}
}

func TestClient_Preflight_NilExec(t *testing.T) {
	c := &Client{leadPeer: "lead:22"} // exec is nil
	if err := c.Init(context.Background(), "n", "d", "a"); err == nil {
		t.Fatal("Init with nil exec: expected error, got nil")
	}
}

func TestInit_Success(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	caPEM := []byte("-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n")
	mx.responses = []mockResp{
		{match: "step ca init", out: nil, err: nil},
		{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
	}
	if err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443"); err != nil {
		t.Fatalf("Init: %v", err)
	}
	containsCall(t, mx, "step ca init --name 'orca'")
	containsCall(t, mx, "--dns 'ca.orca.local'")
	containsCall(t, mx, "--address ':8443'")
	containsCall(t, mx, "--provisioner orca-oidc")
	containsCall(t, mx, "--deployment-type standalone")
	// Root CA mirrored to paths.CACertPath().
	got, err := os.ReadFile(paths.CACertPath())
	if err != nil {
		t.Fatalf("read mirrored CA: %v", err)
	}
	if string(got) != string(caPEM) {
		t.Errorf("mirrored CA = %q, want %q", got, caPEM)
	}
}

func TestInit_StepCLIFails(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	stepErr := errors.New("step: non-zero exit 1")
	mx.responses = []mockResp{
		{match: "step ca init", out: nil, err: stepErr},
	}
	err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
	if err == nil {
		t.Fatal("Init: expected error, got nil")
	}
	if !strings.Contains(err.Error(), "stepca: init") {
		t.Errorf("err = %v, want wrapped 'stepca: init'", err)
	}
}

func TestInit_EmptyRootCA(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	mx.responses = []mockResp{
		{match: "step ca init", out: nil, err: nil},
		{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: nil, err: nil},
	}
	err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
	if err == nil {
		t.Fatal("Init with empty root CA: expected error, got nil")
	}
	if !errors.Is(err, ErrStepCLI) {
		t.Errorf("err = %v, want ErrStepCLI", err)
	}
}

func TestIssueServerCert_Success(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	certPEM := []byte("SERVER-CERT-PEM")
	keyPEM := []byte("SERVER-KEY-PEM")
	mx.responses = []mockResp{
		{match: "step ca certificate", out: nil, err: nil},
		{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: certPEM, err: nil},
		{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: keyPEM, err: nil},
		{match: "rm -f", out: nil, err: nil},
	}
	gotCert, gotKey, err := c.IssueServerCert(context.Background(), "peer1", []string{"peer1.orca.local", "10.0.0.1"})
	if err != nil {
		t.Fatalf("IssueServerCert: %v", err)
	}
	if gotCert != string(certPEM) {
		t.Errorf("cert = %q", gotCert)
	}
	if gotKey != string(keyPEM) {
		t.Errorf("key = %q", gotKey)
	}
	containsCall(t, mx, "step ca certificate 'peer1'")
	containsCall(t, mx, "--not-after '2160h'")
	containsCall(t, mx, "--san 'peer1.orca.local'")
	containsCall(t, mx, "--san '10.0.0.1'")
	// Server cert path must NOT pin a provisioner (uses default).
	for _, call := range mx.calls {
		if strings.HasPrefix(call, "step ca certificate") && strings.Contains(call, "--provisioner") {
			t.Errorf("server cert should not pin provisioner; cmd: %s", call)
		}
	}
}

func TestIssueSVID_Success(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	spiffe := "spiffe://orca/ns/_defaults/job/web/alloc/0"
	certPEM := []byte("SVID-CERT-PEM")
	keyPEM := []byte("SVID-KEY-PEM")
	mx.responses = []mockResp{
		{match: "step ca certificate", out: nil, err: nil},
		{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil},
		{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil},
		{match: "rm -f", out: nil, err: nil},
	}
	gotCert, gotKey, err := c.IssueSVID(context.Background(), spiffe, []string{"web.orca.local"})
	if err != nil {
		t.Fatalf("IssueSVID: %v", err)
	}
	if gotCert != string(certPEM) || gotKey != string(keyPEM) {
		t.Errorf("cert/key mismatch")
	}
	containsCall(t, mx, "step ca certificate")
	containsCall(t, mx, "--not-after '24h'")
	containsCall(t, mx, "--provisioner 'orca-oidc'")
	// SPIFFE ID is both the subject AND a SAN.
	containsCall(t, mx, "--san '"+spiffe+"'")
}

func TestIssueServerCert_StepFails(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	mx.responses = []mockResp{
		{match: "step ca certificate", out: nil, err: errors.New("step: exit 1")},
	}
	_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
	if err == nil || !strings.Contains(err.Error(), "stepca: issue") {
		t.Errorf("err = %v, want wrapped 'stepca: issue'", err)
	}
}

func TestIssueServerCert_ReadCertFails(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	mx.responses = []mockResp{
		{match: "step ca certificate", out: nil, err: nil},
		{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")},
		{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: nil, err: nil},
	}
	_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
	if err == nil || !strings.Contains(err.Error(), "read") {
		t.Errorf("err = %v, want wrapped 'read'", err)
	}
}

func TestIssueServerCert_EmptyCert(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	mx.responses = []mockResp{
		{match: "step ca certificate", out: nil, err: nil},
		{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: nil},
		{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: []byte("KEY"), err: nil},
		{match: "rm -f", out: nil, err: nil},
	}
	_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
	if err == nil || !errors.Is(err, ErrStepCLI) {
		t.Errorf("err = %v, want ErrStepCLI", err)
	}
}

func TestRenewServerCert_Success(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	mx.responses = []mockResp{
		{match: "step ca renew", out: nil, err: nil},
	}
	if err := c.RenewServerCert(context.Background(), "peer1"); err != nil {
		t.Fatalf("RenewServerCert: %v", err)
	}
	containsCall(t, mx, "step ca renew '/etc/orca/step-tmp/orca-peer1.crt' '/etc/orca/step-tmp/orca-peer1.key' --force")
}

func TestRenewServerCert_Fails(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	mx.responses = []mockResp{
		{match: "step ca renew", out: nil, err: errors.New("step: renew failed")},
	}
	err := c.RenewServerCert(context.Background(), "peer1")
	if err == nil || !strings.Contains(err.Error(), "stepca: renew") {
		t.Errorf("err = %v, want wrapped 'stepca: renew'", err)
	}
}

func TestFingerprint_Success(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	mx.responses = []mockResp{
		{match: "step certificate fingerprint", out: []byte("a1b2c3d4e5f6\n"), err: nil},
	}
	fp, err := c.Fingerprint(context.Background())
	if err != nil {
		t.Fatalf("Fingerprint: %v", err)
	}
	if fp != "a1b2c3d4e5f6" {
		t.Errorf("fp = %q, want a1b2c3d4e5f6 (trimmed)", fp)
	}
	containsCall(t, mx, "step certificate fingerprint '/etc/step-ca/certs/root_ca.crt'")
}

func TestFingerprint_Empty(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	mx.responses = []mockResp{
		{match: "step certificate fingerprint", out: []byte(""), err: nil},
	}
	_, err := c.Fingerprint(context.Background())
	if err == nil || !errors.Is(err, ErrStepCLI) {
		t.Errorf("err = %v, want ErrStepCLI", err)
	}
}

func TestFingerprint_Fails(t *testing.T) {
	c, mx := newMockClient(t, "lead:22")
	mx.responses = []mockResp{
		{match: "step certificate fingerprint", out: nil, err: errors.New("ssh: exec failed")},
	}
	_, err := c.Fingerprint(context.Background())
	if err == nil || !strings.Contains(err.Error(), "stepca: fingerprint") {
		t.Errorf("err = %v, want wrapped 'stepca: fingerprint'", err)
	}
}

func TestInit_MkdirFails(t *testing.T) {
	// Point ORCA_HOME at a path that cannot be created under to
	// force MkdirAll failure. We use a file as the parent.
	dir := t.TempDir()
	blocker := filepath.Join(dir, "block")
	if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
		t.Fatalf("write blocker: %v", err)
	}
	t.Setenv("ORCA_HOME", filepath.Join(blocker, "sub"))
	// Construct the client directly (not newMockClient, which
	// resets ORCA_HOME to a fresh temp dir).
	mx := &mockExec{}
	caPEM := []byte("FAKE")
	mx.responses = []mockResp{
		{match: "step ca init", out: nil, err: nil},
		{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
	}
	c := NewClient(nil, "lead:22")
	c.exec = mx
	err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
	if err == nil {
		t.Fatal("Init: expected mkdir error, got nil")
	}
	if !strings.Contains(err.Error(), "mkdir") {
		t.Errorf("err = %v, want 'mkdir'", err)
	}
}

func TestShellQuote(t *testing.T) {
	got := shellQuote("a'b")
	want := "'a'\\''b'"
	if got != want {
		t.Errorf("shellQuote = %q, want %q", got, want)
	}
}

func TestSanitize(t *testing.T) {
	cases := []struct{ in, want string }{
		{"spiffe://orca/ns/_defaults/job/web/alloc/0",
			"spiffe-orca_ns__defaults_job_web_alloc_0"},
		{"plain-host", "plain-host"},
		{"a b", "a_b"},
	}
	for _, tc := range cases {
		if got := sanitize(tc.in); got != tc.want {
			t.Errorf("sanitize(%q) = %q, want %q", tc.in, got, tc.want)
		}
	}
}
