package txn

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"testing"

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

func testKey() []byte {
	return []byte("0123456789abcdef0123456789abcdef")
}

func mustRender(t *testing.T, desired any) *Bundle {
	t.Helper()
	b, err := RenderBundle(desired, testKey())
	if err != nil {
		t.Fatalf("RenderBundle: %v", err)
	}
	return b
}

func TestRenderBundleValidIDAndSignature(t *testing.T) {
	desired := []map[string]any{
		{"path": "/etc/orca/x.conf", "content": "hello", "mode": "0644"},
	}
	b := mustRender(t, desired)

	if !strings.HasPrefix(string(b.ID), "T-") {
		t.Fatalf("ID %q missing T- prefix", b.ID)
	}
	if len(strings.TrimPrefix(string(b.ID), "T-")) != txnIDHexLen {
		t.Fatalf("ID %q suffix not %d hex chars", b.ID, txnIDHexLen)
	}

	// Deterministic: same input => same ID.
	b2 := mustRender(t, desired)
	if b.ID != b2.ID {
		t.Fatalf("non-deterministic ID: %s vs %s", b.ID, b2.ID)
	}

	// Manifest signature verifies.
	if err := VerifyManifestSignature(b.Manifest, b.ManifestSig, testKey()); err != nil {
		t.Fatalf("VerifyManifestSignature: %v", err)
	}

	// Manifest lists all four bundle files + desired-state.
	var m Manifest
	if err := json.Unmarshal(b.Manifest, &m); err != nil {
		t.Fatalf("unmarshal manifest: %v", err)
	}
	if m.TxnID != b.ID {
		t.Errorf("manifest TxnID %q != bundle ID %q", m.TxnID, b.ID)
	}
	names := map[string]bool{}
	for _, e := range m.Files {
		names[e.Name] = true
		if e.SHA256 == "" {
			t.Errorf("manifest entry %s missing sha256", e.Name)
		}
	}
	for _, want := range []string{fileDesiredState, fileApply, fileVerify, fileRollback} {
		if !names[want] {
			t.Errorf("manifest missing file %s", want)
		}
	}

	// Scripts are bash.
	if !strings.HasPrefix(string(b.ApplyScript), "#!/usr/bin/env bash") {
		t.Errorf("apply.sh not bash")
	}
	if !strings.HasPrefix(string(b.VerifyScript), "#!/usr/bin/env bash") {
		t.Errorf("verify.sh not bash")
	}
	if !strings.HasPrefix(string(b.RollbackScript), "#!/usr/bin/env bash") {
		t.Errorf("rollback.sh not bash")
	}
}

func TestRenderBundleContentAddressed(t *testing.T) {
	a := mustRender(t, []map[string]any{{"path": "/a"}})
	b := mustRender(t, []map[string]any{{"path": "/b"}})
	if a.ID == b.ID {
		t.Fatalf("distinct desired states yielded same TxnID %s", a.ID)
	}
}

func TestRenderBundleRejectsEmptyKey(t *testing.T) {
	_, err := RenderBundle([]string{"x"}, nil)
	if err == nil {
		t.Fatal("RenderBundle with empty key should fail")
	}
}

func TestVerifyManifestSignatureTampered(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/x"}})

	// Tamper the manifest content.
	tampered := make([]byte, len(b.Manifest))
	copy(tampered, b.Manifest)
	tampered[0] ^= 0xff
	err := VerifyManifestSignature(tampered, b.ManifestSig, testKey())
	if !errors.Is(err, ErrSignatureMismatch) {
		t.Fatalf("tampered manifest: got %v, want ErrSignatureMismatch", err)
	}

	// Tamper the signature (flip a hex char so the digest changes
	// but stays valid hex).
	badSig := make([]byte, len(b.ManifestSig))
	copy(badSig, b.ManifestSig)
	if len(badSig) > 0 {
		// Flip the first hex char between 0 and 1.
		if badSig[0] == '0' {
			badSig[0] = '1'
		} else {
			badSig[0] = '0'
		}
	}
	err = VerifyManifestSignature(b.Manifest, badSig, testKey())
	if !errors.Is(err, ErrSignatureMismatch) {
		t.Fatalf("tampered sig: got %v, want ErrSignatureMismatch", err)
	}

	// Wrong key.
	wrongKey := []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
	err = VerifyManifestSignature(b.Manifest, b.ManifestSig, wrongKey)
	if !errors.Is(err, ErrSignatureMismatch) {
		t.Fatalf("wrong key: got %v, want ErrSignatureMismatch", err)
	}
}

// mockTransport records all operations against an in-memory file map.
type mockTransport struct {
	mu      sync.Mutex
	files   map[string][]byte
	execOut map[string][]byte
	execErr map[string]error
	writes  []string
	execs   []string
}

func newMockTransport() *mockTransport {
	return &mockTransport{
		files:   make(map[string][]byte),
		execOut: make(map[string][]byte),
		execErr: make(map[string]error),
	}
}

func (m *mockTransport) WriteFileIdempotent(_ context.Context, _ string, path string, content []byte, _ os.FileMode) (bool, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.writes = append(m.writes, path)
	if existing, ok := m.files[path]; ok && string(existing) == string(content) {
		return false, nil
	}
	cp := make([]byte, len(content))
	copy(cp, content)
	m.files[path] = cp
	return true, nil
}

func (m *mockTransport) Exec(_ context.Context, _ string, cmd string) ([]byte, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.execs = append(m.execs, cmd)
	// Match by substring key in execOut/execErr maps.
	for key, out := range m.execOut {
		if strings.Contains(cmd, key) {
			if err, ok := m.execErr[key]; ok {
				return out, err
			}
			return out, nil
		}
	}
	for key, err := range m.execErr {
		if strings.Contains(cmd, key) {
			return m.execOut[key], err
		}
	}
	return nil, nil
}

func (m *mockTransport) hasFile(path string) bool {
	m.mu.Lock()
	defer m.mu.Unlock()
	_, ok := m.files[path]
	return ok
}

func TestStageWritesBundleFiles(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/etc/orca/x.conf", "content": "hello"}})
	mt := newMockTransport()

	if err := Stage(b, "lead:22", mt); err != nil {
		t.Fatalf("Stage: %v", err)
	}

	dir := remoteTxnDir(b.ID)
	for _, name := range []string{fileDesiredState, fileApply, fileVerify, fileRollback, fileManifest, fileManifestSig} {
		if !mt.hasFile(dir + "/" + name) {
			t.Errorf("staged file missing: %s", name)
		}
	}
}

func TestStageNilBundle(t *testing.T) {
	mt := newMockTransport()
	if err := Stage(nil, "lead:22", mt); err == nil {
		t.Fatal("Stage(nil) should fail")
	}
}

func TestStageNilTransport(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/x"}})
	if err := Stage(b, "lead:22", nil); err == nil {
		t.Fatal("Stage with nil transport should fail")
	}
}

func TestApplyClusterWideSuccess(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/etc/orca/x.conf", "content": "hello"}})
	mt := newMockTransport()
	if err := Stage(b, "lead:22", mt); err != nil {
		t.Fatalf("Stage: %v", err)
	}

	mt.execOut["orca-pull.sh"] = []byte("applied")
	err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
		Force:           true,
		AcknowledgeRisk: true,
	})
	if err != nil {
		t.Fatalf("Apply: %v", err)
	}
	if len(mt.execs) != 1 {
		t.Fatalf("expected 1 exec, got %d", len(mt.execs))
	}
	cmd := mt.execs[0]
	if !strings.Contains(cmd, "--force") {
		t.Errorf("cmd missing --force: %s", cmd)
	}
	if !strings.Contains(cmd, "--i-understand-the-risk") {
		t.Errorf("cmd missing --i-understand-the-risk: %s", cmd)
	}
	if strings.Contains(cmd, "--namespace") {
		t.Errorf("cluster-wide cmd should not have --namespace: %s", cmd)
	}
}

func TestApplyClusterWideYes(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/x"}})
	mt := newMockTransport()
	_ = Stage(b, "lead:22", mt)
	mt.execOut["orca-pull.sh"] = []byte("applied")
	err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
		Force: true,
		Yes:   true,
	})
	if err != nil {
		t.Fatalf("Apply --yes: %v", err)
	}
	if !strings.Contains(mt.execs[0], "--yes") {
		t.Errorf("cmd missing --yes: %s", mt.execs[0])
	}
}

func TestApplyNamespaceScopedSuccess(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/x"}})
	mt := newMockTransport()
	_ = Stage(b, "lead:22", mt)
	mt.execOut["orca-pull.sh"] = []byte("applied")

	err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
		Namespace: "default",
	})
	if err != nil {
		t.Fatalf("Apply ns-scoped: %v", err)
	}
	cmd := mt.execs[0]
	if !strings.Contains(cmd, "--namespace") {
		t.Errorf("ns-scoped cmd missing --namespace: %s", cmd)
	}
	if strings.Contains(cmd, "--force") {
		t.Errorf("ns-scoped cmd should not have --force: %s", cmd)
	}
}

func TestApplyClusterWideRefusesWithoutForce(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/x"}})
	mt := newMockTransport()
	_ = Stage(b, "lead:22", mt)

	err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{})
	if !errors.Is(err, ErrClusterWideRequiresForce) {
		t.Fatalf("expected ErrClusterWideRequiresForce, got %v", err)
	}
	if len(mt.execs) != 0 {
		t.Errorf("should not exec when --force missing")
	}
}

func TestApplyClusterWideRefusesWithoutAck(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/x"}})
	mt := newMockTransport()
	_ = Stage(b, "lead:22", mt)

	err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{Force: true})
	if !errors.Is(err, ErrClusterWideRequiresAck) {
		t.Fatalf("expected ErrClusterWideRequiresAck, got %v", err)
	}
}

func TestApplyIdempotentNoOp(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/x"}})
	mt := newMockTransport()
	_ = Stage(b, "lead:22", mt)

	// Lead returns exit 5 = already-applied no-op.
	mt.execErr["orca-pull.sh"] = fmt.Errorf("%w: exit 5", sshpush.ErrPermanent)
	mt.execOut["orca-pull.sh"] = []byte("already-applied")

	err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
		Force:           true,
		AcknowledgeRisk: true,
	})
	if !errors.Is(err, ErrAlreadyApplied) {
		t.Fatalf("expected ErrAlreadyApplied, got %v", err)
	}
}

func TestApplyFailureReturnsError(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/x"}})
	mt := newMockTransport()
	_ = Stage(b, "lead:22", mt)

	mt.execErr["orca-pull.sh"] = fmt.Errorf("%w: exit 1", sshpush.ErrPermanent)
	mt.execOut["orca-pull.sh"] = []byte("apply failure")

	err := Apply(context.Background(), b.ID, "lead:22", mt, ApplyOptions{
		Force:           true,
		AcknowledgeRisk: true,
	})
	if err == nil {
		t.Fatal("Apply should fail on exit 1")
	}
	if errors.Is(err, ErrAlreadyApplied) {
		t.Fatal("exit 1 should not be treated as already-applied")
	}
}

func TestApplyNilTransport(t *testing.T) {
	b := mustRender(t, []map[string]any{{"path": "/x"}})
	err := Apply(context.Background(), b.ID, "lead:22", nil, ApplyOptions{
		Force:           true,
		AcknowledgeRisk: true,
	})
	if err == nil {
		t.Fatal("Apply with nil transport should fail")
	}
}

func TestSignManifestDeterministic(t *testing.T) {
	manifest := []byte(`{"txn_id":"T-abc"}`)
	sig := signManifest(manifest, testKey())

	mac := hmac.New(sha256.New, testKey())
	mac.Write(manifest)
	want := hex.EncodeToString(mac.Sum(nil))
	if string(sig) != want {
		t.Fatalf("signManifest: got %q want %q", sig, want)
	}
}

func TestComputeTxnIDStable(t *testing.T) {
	data := []byte(`{"x":1}`)
	id, err := computeTxnID(data)
	if err != nil {
		t.Fatalf("computeTxnID: %v", err)
	}
	sum := sha256.Sum256(data)
	h := hex.EncodeToString(sum[:])
	want := TxnID("T-" + h[:txnIDHexLen])
	if id != want {
		t.Fatalf("computeTxnID: got %q want %q", id, want)
	}
}

// --- REQ-121 / F5 txn apply path allowlist tests ---

// TestApplyScriptRejectsDisallowedPath verifies the generated apply.sh
// refuses to write paths outside the allowlist. We render a bundle
// with a disallowed path, extract the apply.sh, run it with a crafted
// desired-state.json, and assert it exits 7 (the refusal code) without
// writing the file.
func TestApplyScriptRejectsDisallowedPath(t *testing.T) {
	if testing.Short() {
		t.Skip("apply.sh exec test skipped in -short mode")
	}
	disallowed := []string{
		"/etc/shadow",
		"/root/.ssh/authorized_keys",
		"/etc/passwd",
		"/tmp/pwned",
		"/etc/orca/../../shadow",
		"relative/path",
	}
	for _, p := range disallowed {
		t.Run(p, func(t *testing.T) {
			b := mustRender(t, []map[string]any{{"path": p, "content": "pwned"}})
			// Write apply.sh + desired-state.json to a temp dir.
			dir := t.TempDir()
			if err := os.WriteFile(filepath.Join(dir, fileDesiredState), b.DesiredState, 0o600); err != nil {
				t.Fatalf("write desired-state: %v", err)
			}
			if err := os.WriteFile(filepath.Join(dir, fileApply), b.ApplyScript, 0o755); err != nil {
				t.Fatalf("write apply.sh: %v", err)
			}
			cmd := exec.Command("bash", filepath.Join(dir, fileApply))
			out, err := cmd.CombinedOutput()
			if err == nil {
				t.Fatalf("apply.sh should fail for path %s, got success; output: %s", p, out)
			}
			if !strings.Contains(string(out), "refusing to write disallowed path") {
				t.Errorf("apply.sh output should mention refusal: %s", out)
			}
		})
	}
}

// TestApplyScriptAllowsOrcaPaths verifies the allowed prefixes work.
func TestApplyScriptAllowsOrcaPaths(t *testing.T) {
	if testing.Short() {
		t.Skip("apply.sh exec test skipped in -short mode")
	}
	// We can't actually write to /etc/ in a test, so we verify the
	// allowlist logic in the generated script by checking the script
	// content contains the allowlist and the path_allowed function.
	b := mustRender(t, []map[string]any{{"path": "/etc/orca/test"}})
	script := string(b.ApplyScript)
	if !strings.Contains(script, "ALLOWED_PREFIXES") {
		t.Error("apply.sh missing ALLOWED_PREFIXES")
	}
	if !strings.Contains(script, "path_allowed") {
		t.Error("apply.sh missing path_allowed function")
	}
	for _, prefix := range []string{
		"/etc/orca/",
		"/etc/traefik/orca",
		"/etc/systemd/system/orca-",
		"/etc/nftables.d/orca",
		"/etc/syncthing/orca",
	} {
		if !strings.Contains(script, prefix) {
			t.Errorf("apply.sh missing allowed prefix %s", prefix)
		}
	}
}
