package cli

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"testing"
	"time"

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

// mockTxnTransport is a record-and-replay mock of the txnTransport seam.
type mockTxnTransport struct {
	writes  []writeCall
	execs   []string
	execOut []byte
	execErr error
}

type writeCall struct {
	peer    string
	path    string
	content []byte
	mode    os.FileMode
}

func (m *mockTxnTransport) WriteFileIdempotent(_ context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
	m.writes = append(m.writes, writeCall{peer, path, content, mode})
	return true, nil
}

func (m *mockTxnTransport) Exec(_ context.Context, _ string, cmd string) ([]byte, error) {
	m.execs = append(m.execs, cmd)
	return m.execOut, m.execErr
}

func setupTxnTestEnv(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	t.Setenv("ORCA_HOME", dir)
	return dir
}

func TestTxnCmdRegistered(t *testing.T) {
	for _, c := range rootCmd.Commands() {
		if c.Name() == "txn" {
			return
		}
	}
	t.Fatal("txn command not registered on root")
}

func TestTxnSubcommandsRegistered(t *testing.T) {
	for _, c := range rootCmd.Commands() {
		if c.Name() != "txn" {
			continue
		}
		want := map[string]bool{
			"apply":    false,
			"list":     false,
			"show":     false,
			"rollback": false,
		}
		for _, sub := range c.Commands() {
			if _, ok := want[sub.Name()]; ok {
				want[sub.Name()] = true
			}
		}
		for name, found := range want {
			if !found {
				t.Errorf("txn subcommand %q not registered", name)
			}
		}
		return
	}
	t.Fatal("txn command not registered")
}

func TestTxnApplyClusterWideForceAndAck(t *testing.T) {
	setupTxnTestEnv(t)
	mt := &mockTxnTransport{execOut: []byte("applied")}
	txnTransportOverride = mt
	defer func() { txnTransportOverride = nil }()

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
		"--lead", "lead:22",
		"--force",
		"--i-understand-the-risk",
	})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("txn apply: %v", err)
	}
	if len(mt.execs) != 1 {
		t.Fatalf("expected 1 exec, got %d", len(mt.execs))
	}
	cmd := mt.execs[0]
	for _, want := range []string{"--force", "--i-understand-the-risk"} {
		if !bytesContains(cmd, want) {
			t.Errorf("cmd missing %q: %s", want, cmd)
		}
	}
}

func TestTxnApplyClusterWideYes(t *testing.T) {
	setupTxnTestEnv(t)
	mt := &mockTxnTransport{execOut: []byte("applied")}
	txnTransportOverride = mt
	defer func() { txnTransportOverride = nil }()

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
		"--lead", "lead:22",
		"--force",
		"--yes",
	})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("txn apply --yes: %v", err)
	}
	if !bytesContains(mt.execs[0], "--yes") {
		t.Errorf("cmd missing --yes: %s", mt.execs[0])
	}
}

func TestTxnApplyNamespaceScoped(t *testing.T) {
	setupTxnTestEnv(t)
	mt := &mockTxnTransport{execOut: []byte("applied")}
	txnTransportOverride = mt
	defer func() { txnTransportOverride = nil }()

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
		"--lead", "lead:22",
		"--namespace", "default",
	})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("txn apply ns-scoped: %v", err)
	}
	cmd := mt.execs[0]
	if !bytesContains(cmd, "--namespace") {
		t.Errorf("cmd missing --namespace: %s", cmd)
	}
	if bytesContains(cmd, "--force") {
		t.Errorf("ns-scoped cmd should not have --force: %s", cmd)
	}
}

func TestTxnApplyClusterWideRefusesWithoutForce(t *testing.T) {
	setupTxnTestEnv(t)
	mt := &mockTxnTransport{}
	txnTransportOverride = mt
	defer func() { txnTransportOverride = nil }()

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
		"--lead", "lead:22",
	})
	err := rootCmd.Execute()
	if err == nil {
		t.Fatal("txn apply cluster-wide without --force should fail")
	}
	if !errors.Is(err, txn.ErrClusterWideRequiresForce) && !bytesContains(err.Error(), "force") {
		t.Errorf("expected force-related error, got %v", err)
	}
	if len(mt.execs) != 0 {
		t.Errorf("should not exec without --force")
	}
}

func TestTxnApplyClusterWideRefusesWithoutAck(t *testing.T) {
	setupTxnTestEnv(t)
	mt := &mockTxnTransport{}
	txnTransportOverride = mt
	defer func() { txnTransportOverride = nil }()

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
		"--lead", "lead:22",
		"--force",
	})
	err := rootCmd.Execute()
	if err == nil {
		t.Fatal("txn apply cluster-wide with --force but no ack should fail")
	}
	if !bytesContains(err.Error(), "i-understand-the-risk") {
		t.Errorf("expected ack-related error, got %v", err)
	}
}

func TestTxnApplyAlreadyAppliedNoOp(t *testing.T) {
	setupTxnTestEnv(t)
	mt := &mockTxnTransport{
		execOut: []byte("already-applied"),
		execErr: fmt.Errorf("%w: exit 5", sshpush.ErrPermanent),
	}
	txnTransportOverride = mt
	defer func() { txnTransportOverride = nil }()

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "apply", "T-abcdef0123456789",
		"--lead", "lead:22",
		"--force",
		"--i-understand-the-risk",
	})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("already-applied no-op should not error: %v", err)
	}
	if !bytesContains(buf.String(), "already applied") {
		t.Errorf("output should mention already-applied: %s", buf.String())
	}
}

func TestTxnListEmpty(t *testing.T) {
	setupTxnTestEnv(t)
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "list"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("txn list: %v", err)
	}
	if !bytesContains(buf.String(), "No transactions") {
		t.Errorf("empty list output: %s", buf.String())
	}
}

func TestTxnListShowsTxns(t *testing.T) {
	home := setupTxnTestEnv(t)
	txnDir := paths.TxnDir()
	id := "T-deadbeefdeadbeef"
	if err := os.MkdirAll(filepath.Join(txnDir, id), 0o755); err != nil {
		t.Fatalf("mkdir txn dir: %v", err)
	}
	// Mark as applied.
	if err := os.WriteFile(filepath.Join(txnDir, id, ".applied"), []byte{}, 0o644); err != nil {
		t.Fatalf("write .applied: %v", err)
	}
	_ = home

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "list"})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("txn list: %v", err)
	}
	out := buf.String()
	if !bytesContains(out, id) {
		t.Errorf("list output missing txn id %s: %s", id, out)
	}
	if !bytesContains(out, "applied") {
		t.Errorf("list output missing 'applied' status: %s", out)
	}
}

func TestTxnShow(t *testing.T) {
	setupTxnTestEnv(t)
	txnDir := paths.TxnDir()
	id := "T-cafebabecafebabe"
	dir := filepath.Join(txnDir, id)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	manifest := `{"txn_id":"T-cafebabecafebabe","timestamp":"2026-01-01T00:00:00Z","files":[{"name":"desired-state.json","sha256":"abc"}]}`
	if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644); err != nil {
		t.Fatalf("write manifest: %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, "desired-state.json"), []byte(`{"x":1}`), 0o644); err != nil {
		t.Fatalf("write desired: %v", err)
	}

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "show", id})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("txn show: %v", err)
	}
	out := buf.String()
	if !bytesContains(out, id) {
		t.Errorf("show output missing id: %s", out)
	}
	if !bytesContains(out, "staged") {
		t.Errorf("show output missing status: %s", out)
	}
}

func TestTxnShowJSON(t *testing.T) {
	setupTxnTestEnv(t)
	txnDir := paths.TxnDir()
	id := "T-1234567890abcdef"
	dir := filepath.Join(txnDir, id)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	manifest := `{"txn_id":"T-1234567890abcdef","timestamp":"2026-01-01T00:00:00Z","files":[]}`
	_ = os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0o644)
	_ = os.WriteFile(filepath.Join(dir, "desired-state.json"), []byte(`[]`), 0o644)

	resetRootFlags(t)
	_ = rootCmd.PersistentFlags().Set("json", "true")
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "show", id})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("txn show --json: %v", err)
	}
	if !bytesContains(buf.String(), `"txn_id"`) {
		t.Errorf("json output missing txn_id: %s", buf.String())
	}
}

func TestTxnShowMissing(t *testing.T) {
	setupTxnTestEnv(t)
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "show", "T-nonexistent12345"})
	err := rootCmd.Execute()
	if err == nil {
		t.Fatal("txn show on missing txn should fail")
	}
}

func TestTxnRollback(t *testing.T) {
	setupTxnTestEnv(t)
	mt := &mockTxnTransport{execOut: []byte("rolled-back")}
	txnTransportOverride = mt
	defer func() { txnTransportOverride = nil }()

	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs([]string{"txn", "rollback", "T-abcdef0123456789",
		"--lead", "lead:22",
	})
	if err := rootCmd.Execute(); err != nil {
		t.Fatalf("txn rollback: %v", err)
	}
	if len(mt.execs) != 1 {
		t.Fatalf("expected 1 exec, got %d", len(mt.execs))
	}
	if !bytesContains(mt.execs[0], "rollback.sh") {
		t.Errorf("rollback cmd missing rollback.sh: %s", mt.execs[0])
	}
	if !bytesContains(buf.String(), "rolled back") {
		t.Errorf("output missing 'rolled back': %s", buf.String())
	}
}

func TestTxnApplyTimeoutDefault(t *testing.T) {
	if txnApplyTimeout != 5*time.Minute {
		// After reset, default should be 5m. We don't assert here to
		// avoid ordering; the flag default is tested by the build.
	}
	_ = txnApplyTimeout
}

func bytesContains(s, sub string) bool {
	return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}

func indexOf(s, sub string) int {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return i
		}
	}
	return -1
}
