package emitter

import (
	"context"
	"errors"
	"os"
	"strings"
	"testing"
)

// mockAtomicWriter is a test-only AtomicWriter that records calls so
// the C-10 atomicity protocol (tmp + rename) can be asserted.
type mockAtomicWriter struct {
	written    []writeCall
	execed     []execCall
	writeErr   error
	writeWrote bool
	execErr    error
}

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

type execCall struct {
	peer string
	cmd  string
}

func (m *mockAtomicWriter) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
	m.written = append(m.written, writeCall{peer: peer, path: path, mode: mode, bytes: append([]byte(nil), content...)})
	if m.writeErr != nil {
		return false, m.writeErr
	}
	return m.writeWrote, nil
}

func (m *mockAtomicWriter) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
	m.execed = append(m.execed, execCall{peer: peer, cmd: cmd})
	if m.execErr != nil {
		return nil, m.execErr
	}
	return []byte("ok"), nil
}

func TestWriteTraefikDynamic_TmpThenRename(t *testing.T) {
	// Gate C-10: the Traefik dynamic-config write must be a tmp +
	// rename sequence so Traefik's fsnotify watcher never observes a
	// half-written file.
	mock := &mockAtomicWriter{writeWrote: true}
	path := "/etc/traefik/dynamic/orca-web.yaml"
	peer := "node-1:22"
	content := []byte("http:\n  routers: {}\n")

	if err := WriteTraefikDynamic(context.Background(), mock, peer, path, content); err != nil {
		t.Fatalf("WriteTraefikDynamic: %v", err)
	}

	if len(mock.written) != 1 {
		t.Fatalf("WriteFileIdempotent calls = %d, want 1", len(mock.written))
	}
	w := mock.written[0]
	if w.peer != peer {
		t.Errorf("write peer = %q, want %q", w.peer, peer)
	}
	// The tmp path is the target path + ".tmp".
	if w.path != path+".tmp" {
		t.Errorf("write path = %q, want %q (.tmp suffix is the C-10 atomicity protocol)", w.path, path+".tmp")
	}
	if string(w.bytes) != string(content) {
		t.Errorf("write content = %q, want %q", string(w.bytes), string(content))
	}
	if w.mode != 0o644 {
		t.Errorf("write mode = %o, want 0644", w.mode)
	}

	if len(mock.execed) != 1 {
		t.Fatalf("Exec calls = %d, want 1 (the rename)", len(mock.execed))
	}
	e := mock.execed[0]
	if e.peer != peer {
		t.Errorf("exec peer = %q, want %q", e.peer, peer)
	}
	// The rename command must `mv -f` the .tmp file to the final path.
	if !strings.Contains(e.cmd, "mv -f") {
		t.Errorf("exec cmd = %q, want it to contain 'mv -f' (atomic rename)", e.cmd)
	}
	if !strings.Contains(e.cmd, path+".tmp") {
		t.Errorf("exec cmd = %q, want it to contain the .tmp path as source", e.cmd)
	}
	if !strings.Contains(e.cmd, path) {
		t.Errorf("exec cmd = %q, want it to contain the final path as destination", e.cmd)
	}
	// Sanity: the source must come before the destination in the
	// mv command.
	srcIdx := strings.Index(e.cmd, path+".tmp")
	dstIdx := strings.Index(e.cmd, "'"+path+"'")
	if srcIdx < 0 || dstIdx < 0 || srcIdx > dstIdx {
		t.Errorf("exec cmd %q: source .tmp must come before destination %s", e.cmd, path)
	}
}

func TestWriteTraefikDynamic_WriteTmpError(t *testing.T) {
	mock := &mockAtomicWriter{writeErr: errors.New("disk full")}
	err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x"))
	if err == nil {
		t.Fatal("expected error from WriteFileIdempotent, got nil")
	}
	if !strings.Contains(err.Error(), "write tmp") {
		t.Errorf("error = %q, want 'write tmp'", err.Error())
	}
	if !strings.Contains(err.Error(), "disk full") {
		t.Errorf("error = %q, want underlying 'disk full'", err.Error())
	}
	if len(mock.execed) != 0 {
		t.Errorf("on tmp write failure, no rename should happen; execed = %v", mock.execed)
	}
}

func TestWriteTraefikDynamic_RenameError(t *testing.T) {
	mock := &mockAtomicWriter{writeWrote: true, execErr: errors.New("permission denied")}
	err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x"))
	if err == nil {
		t.Fatal("expected error from rename, got nil")
	}
	if !strings.Contains(err.Error(), "rename") {
		t.Errorf("error = %q, want 'rename'", err.Error())
	}
	if !strings.Contains(err.Error(), "permission denied") {
		t.Errorf("error = %q, want underlying 'permission denied'", err.Error())
	}
}

func TestWriteTraefikDynamic_NilWriter(t *testing.T) {
	err := WriteTraefikDynamic(context.Background(), nil, "p", "/x", []byte("x"))
	if err == nil {
		t.Fatal("expected error for nil writer")
	}
	if !strings.Contains(err.Error(), "nil") {
		t.Errorf("error = %q, want 'nil'", err.Error())
	}
}

func TestWriteTraefikDynamic_EmptyPath(t *testing.T) {
	mock := &mockAtomicWriter{writeWrote: true}
	err := WriteTraefikDynamic(context.Background(), mock, "p", "", []byte("x"))
	if err == nil {
		t.Fatal("expected error for empty path")
	}
	if !strings.Contains(err.Error(), "path is empty") {
		t.Errorf("error = %q, want 'path is empty'", err.Error())
	}
}

func TestWriteTraefikDynamic_SkipWhenContentMatches(t *testing.T) {
	// When the .tmp file already matches (writeWrote=false), the
	// protocol still proceeds with the rename — the idempotency
	// check is per-file, not per-protocol. The rename still happens
	// so the final path reflects the (unchanged) content.
	mock := &mockAtomicWriter{writeWrote: false}
	err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x"))
	if err != nil {
		t.Fatalf("WriteTraefikDynamic: %v", err)
	}
	if len(mock.execed) != 1 {
		t.Errorf("rename should still happen on idempotent skip; execed = %v", mock.execed)
	}
}

func TestShellQuoteLocal(t *testing.T) {
	cases := []struct {
		in, want string
	}{
		{"/etc/traefik/dynamic/orca-web.yaml", "'/etc/traefik/dynamic/orca-web.yaml'"},
		{"", "''"},
		{"/path with space/x", "'/path with space/x'"},
		{"a'b", "'a'\\''b'"},
	}
	for _, tc := range cases {
		t.Run(tc.in, func(t *testing.T) {
			got := shellQuoteLocal(tc.in)
			if got != tc.want {
				t.Errorf("shellQuoteLocal(%q) = %q, want %q", tc.in, got, tc.want)
			}
		})
	}
}
