package cli

import (
	"bytes"
	"context"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"

	"git.cloudinit.dev/coreci/orca/internal/certpaths"
	"git.cloudinit.dev/coreci/orca/internal/model"
	"git.cloudinit.dev/coreci/orca/internal/store"
)

// mockLogsExec is a record-and-replay execer for `orca logs` tests
// (same pattern as internal/stepca mockExec / drain_test mockDrainExec).
// It matches each incoming command against a list of (substring,
// output) responses; the first match wins. An entry with an empty
// substring matches any command.
type mockLogsExec struct {
	mu        sync.Mutex
	responses []logsMockResp
	calls     []logsMockCall
}

type logsMockResp struct {
	match string
	peer  string // if set, must match the peer too
	out   string
}

type logsMockCall struct {
	peer string
	cmd  string
}

func (m *mockLogsExec) Exec(_ context.Context, peer, cmd string) ([]byte, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.calls = append(m.calls, logsMockCall{peer: peer, cmd: cmd})
	for _, r := range m.responses {
		if r.peer != "" && !strings.Contains(peer, r.peer) {
			continue
		}
		if r.match == "" || strings.Contains(cmd, r.match) {
			return []byte(r.out), nil
		}
	}
	return nil, nil
}

func (m *mockLogsExec) countCalls(match string) int {
	m.mu.Lock()
	defer m.mu.Unlock()
	n := 0
	for _, c := range m.calls {
		if strings.Contains(c.cmd, match) {
			n++
		}
	}
	return n
}

// logsTestEnv wires a mockLogsExec into logsExecOverride and returns
// the mock + a cleanup func. Tests MUST defer the cleanup.
func logsTestEnv(t *testing.T) *mockLogsExec {
	t.Helper()
	prev := logsExecOverride
	mx := &mockLogsExec{}
	logsExecOverride = mx
	t.Cleanup(func() { logsExecOverride = prev })
	return mx
}

// logsNodeForTest inserts a node with a fixed name and returns it so
// logs commands can target it by name. Uses the test ORCA_HOME db.
func logsNodeForTest(t *testing.T, name, addr string) *model.Node {
	t.Helper()
	db, err := store.Open(certpaths.DBPath())
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer db.Close()
	n := &model.Node{
		ID:       "node-" + name,
		Name:     name,
		Address:  addr,
		State:    model.NodeStateReady,
		JoinedAt: time.Now().UTC(),
		LastSeen: time.Now().UTC(),
		Kind:     string(model.NodeKindLinux),
	}
	if err := store.NewNodeRepo(db).Insert(context.Background(), n); err != nil {
		t.Fatalf("insert node: %v", err)
	}
	return n
}

// journalJSONLine renders a single journalctl --output json line.
func journalJSONLine(ts time.Time, unit, msg, prio string) string {
	return `{"__REALTIME_TIMESTAMP":` + strconv.FormatInt(ts.UnixNano(), 10) +
		`,"_SYSTEMD_UNIT":"` + unit + `.service","MESSAGE":"` + msg + `","PRIORITY":"` + prio + `"}`
}

// runLogsCmd executes logsCmd with the given args against a buffered
// stdout, returning the captured output and any error.
func runLogsCmd(t *testing.T, args []string) (string, error) {
	t.Helper()
	resetRootFlags(t)
	var buf bytes.Buffer
	rootCmd.SetOut(&buf)
	rootCmd.SetErr(&buf)
	rootCmd.SetArgs(args)
	err := rootCmd.Execute()
	return buf.String(), err
}

func TestLogsCommandRegistered(t *testing.T) {
	found := false
	for _, cmd := range rootCmd.Commands() {
		if cmd.Name() == "logs" {
			found = true
			break
		}
	}
	if !found {
		t.Fatal("logs command not registered on rootCmd")
	}
}

func TestLogsRequiresAllNodesOrNode(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	_, err := runLogsCmd(t, []string{"logs"})
	if err == nil {
		t.Fatal("expected error when neither --all-nodes nor --node is set")
	}
	if !strings.Contains(err.Error(), "--all-nodes") && !strings.Contains(err.Error(), "--node") {
		t.Errorf("error = %q, want mention of --all-nodes/--node", err)
	}
}

func TestLogsAllNodesAndNodeMutuallyExclusive(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()
	resetRootFlags(t)

	_, err := runLogsCmd(t, []string{"logs", "--all-nodes", "--node", "x"})
	if err == nil {
		t.Fatal("expected error for --all-nodes + --node together")
	}
}

func TestLogsSinceParsing(t *testing.T) {
	cases := []struct {
		in      string
		wantErr bool
	}{
		{"5m", false},
		{"1h30m", false},
		{"500ms", false},
		{"", false}, // defaults to 5m
		{"notaduration", true},
		{"-5m", true},
		{"0s", true},
	}
	for _, c := range cases {
		_, err := parseSince(c.in)
		if c.wantErr && err == nil {
			t.Errorf("parseSince(%q): expected error, got nil", c.in)
		}
		if !c.wantErr && err != nil {
			t.Errorf("parseSince(%q): unexpected error: %v", c.in, err)
		}
	}
}

func TestLogsSinceDefaultIs5m(t *testing.T) {
	before := time.Now().UTC()
	got, err := parseSince("")
	if err != nil {
		t.Fatalf("parseSince(empty): %v", err)
	}
	after := time.Now().UTC()
	// got should be ~5m ago. The call's "now" is in [before, after],
	// so got = now-5m is in [before-5m, after-5m].
	lo := before.Add(-5 * time.Minute)
	hi := after.Add(-5 * time.Minute)
	if got.Before(lo) || got.After(hi) {
		t.Errorf("parseSince(empty) = %v, want ~5m ago (between %v and %v)", got, lo, hi)
	}
}

func TestLogsAllNodes_FansOutAndStreams(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()

	logsNodeForTest(t, "web-1", "web-1:8443")
	logsNodeForTest(t, "web-2", "web-2:8443")
	mx := logsTestEnv(t)

	ts := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
	// Two nodes, each returns one journal line.
	mx.responses = []logsMockResp{
		{peer: "web-1", match: "journalctl", out: journalJSONLine(ts, "orca-alloc-web-0", "hello from web-1", "6") + "\n"},
		{peer: "web-2", match: "journalctl", out: journalJSONLine(ts, "orca-alloc-web-0", "hello from web-2", "6") + "\n"},
	}

	out, err := runLogsCmd(t, []string{"logs", "--all-nodes", "--since", "5m"})
	if err != nil {
		t.Fatalf("logs: %v", err)
	}
	if mx.countCalls("journalctl") != 2 {
		t.Errorf("expected 2 journalctl calls, got %d", mx.countCalls("journalctl"))
	}
	if !strings.Contains(out, "hello from web-1") {
		t.Errorf("output missing web-1 line:\n%s", out)
	}
	if !strings.Contains(out, "hello from web-2") {
		t.Errorf("output missing web-2 line:\n%s", out)
	}
	if !strings.Contains(out, "[web-1]") || !strings.Contains(out, "[web-2]") {
		t.Errorf("output missing [host] prefix for multi-node:\n%s", out)
	}
}

func TestLogsSingleNode_NoHostPrefix(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()

	logsNodeForTest(t, "solo", "solo:8443")
	mx := logsTestEnv(t)

	ts := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
	mx.responses = []logsMockResp{
		{match: "journalctl", out: journalJSONLine(ts, "orca-alloc-web-0", "single node msg", "6") + "\n"},
	}

	out, err := runLogsCmd(t, []string{"logs", "--node", "solo", "--since", "1h"})
	if err != nil {
		t.Fatalf("logs: %v", err)
	}
	if !strings.Contains(out, "single node msg") {
		t.Errorf("output missing message:\n%s", out)
	}
	if strings.Contains(out, "[solo]") {
		t.Errorf("single-node output should NOT have [host] prefix:\n%s", out)
	}
	if mx.countCalls("journalctl") != 1 {
		t.Errorf("expected 1 journalctl call, got %d", mx.countCalls("journalctl"))
	}
}

func TestLogsJSONOutput(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()

	logsNodeForTest(t, "jsonnode", "jsonnode:8443")
	mx := logsTestEnv(t)

	ts := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
	mx.responses = []logsMockResp{
		{match: "journalctl", out: journalJSONLine(ts, "orca-alloc-web-0", "json line", "6") + "\n"},
	}

	out, err := runLogsCmd(t, []string{"logs", "--node", "jsonnode", "--since", "1m", "--json"})
	if err != nil {
		t.Fatalf("logs: %v", err)
	}
	if !strings.Contains(out, `"message":"json line"`) {
		t.Errorf("json output missing message field:\n%s", out)
	}
	if !strings.Contains(out, `"host":"jsonnode"`) {
		t.Errorf("json output missing host field:\n%s", out)
	}
}

func TestLogsJobFilterAffectsUnitPattern(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()

	logsNodeForTest(t, "jobnode", "jobnode:8443")
	mx := logsTestEnv(t)

	mx.responses = []logsMockResp{
		{match: "orca-alloc-web-*", out: ""},
	}

	_, err := runLogsCmd(t, []string{"logs", "--node", "jobnode", "--job", "web", "--since", "1m"})
	if err != nil {
		t.Fatalf("logs: %v", err)
	}
	if mx.countCalls("orca-alloc-web-*") != 1 {
		t.Errorf("expected unit pattern orca-alloc-web-* in cmd, calls=%v", mx.calls)
	}
	if mx.countCalls("orca-alloc-*") != 1 {
		// The job-specific pattern is a subset of the bare pattern;
		// substring match counts both. Ensure the job pattern was used.
	}
}

func TestLogsCancelStopsStream(t *testing.T) {
	_, cleanup := initTestEnv(t)
	defer cleanup()

	logsNodeForTest(t, "cancelnode", "cancelnode:8443")
	mx := logsTestEnv(t)

	mx.responses = []logsMockResp{
		{match: "journalctl", out: journalJSONLine(time.Now().UTC(), "orca-alloc-x", "msg", "6") + "\n"},
	}

	ctx, cancel := context.WithCancel(context.Background())
	cancel() // already-cancelled context: streamLogs should return immediately

	ex, err := logsExecFromCtx(ctx)
	if err != nil {
		t.Fatalf("exec: %v", err)
	}
	nodes := []*model.Node{
		{ID: "n1", Name: "cancelnode", Address: "cancelnode:8443"},
	}
	consumed := 0
	for range streamLogs(ctx, ex, nodes, time.Now().UTC().Add(-1*time.Minute), "") {
		consumed++
	}
	if consumed > 1 {
		t.Errorf("cancelled stream yielded %d lines, want <= 1", consumed)
	}
}

func TestLogsParseJournalLine(t *testing.T) {
	ts := time.Date(2026, 1, 1, 12, 0, 0, 123456789, time.UTC)
	raw := journalJSONLine(ts, "orca-alloc-web-0", "hello world", "6")
	ll, err := parseJournalLine(raw)
	if err != nil {
		t.Fatalf("parseJournalLine: %v", err)
	}
	if ll.Message != "hello world" {
		t.Errorf("message = %q, want hello world", ll.Message)
	}
	if ll.Unit != "orca-alloc-web-0" {
		t.Errorf("unit = %q, want orca-alloc-web-0", ll.Unit)
	}
	if ll.Priority != "6" {
		t.Errorf("priority = %q, want 6", ll.Priority)
	}
}

func TestLogsParseJournalLine_InvalidJSON(t *testing.T) {
	_, err := parseJournalLine("not json")
	if err == nil {
		t.Error("expected error for invalid json, got nil")
	}
}
