package hub

import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"commilitia.net/cdrop/internal/db"
)

// fakeLister returns a fixed device list and counts calls.
type fakeLister struct {
	devices []db.Device
	calls   atomic.Int32
}

func (f *fakeLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) {
	f.calls.Add(1)
	return f.devices, nil
}

func waitForEvent(t *testing.T, c *Client, want string, timeout time.Duration) Event {
	t.Helper()
	select {
	case ev, ok := <-c.Events():
		if !ok {
			t.Fatalf("event channel closed; wanted type=%q", want)
		}
		if want != "" && ev.Type != want {
			t.Fatalf("event type: got %q, want %q", ev.Type, want)
		}
		return ev
	case <-time.After(timeout):
		t.Fatalf("timeout waiting for event type=%q", want)
	}
	return Event{}
}

func TestConnectFiresPresenceEvent(t *testing.T) {
	lister := &fakeLister{
		devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser", LastSeen: 1}},
	}
	h := New(lister)
	defer h.Close()

	c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
	defer h.Disconnect(c)

	ev := waitForEvent(t, c, "presence", time.Second)
	data, _ := ev.Data.(map[string]any)
	devs, _ := data["devices"].([]PresenceDevice)
	if len(devs) != 1 || devs[0].Name != "tab-1" || devs[0].DeviceID != "dev_1" || !devs[0].Online {
		t.Errorf("presence devices: %+v", devs)
	}
}

func TestSecondClientSeesFirstAsOnline(t *testing.T) {
	lister := &fakeLister{
		devices: []db.Device{
			{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser", LastSeen: 1},
			{UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser", LastSeen: 2},
		},
	}
	h := New(lister)
	defer h.Close()

	c1 := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
	defer h.Disconnect(c1)
	_ = waitForEvent(t, c1, "presence", time.Second)

	c2 := h.Connect(context.Background(), "alice", "dev_2", "tab-2", "browser")
	defer h.Disconnect(c2)

	// c2 receives its own presence (announced on Connect).
	ev := waitForEvent(t, c2, "presence", time.Second)
	devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
	if len(devs) != 2 {
		t.Fatalf("expected 2 devices in presence, got %+v", devs)
	}
	online := map[string]bool{}
	for _, d := range devs {
		online[d.Name] = d.Online
	}
	if !online["tab-1"] || !online["tab-2"] {
		t.Errorf("both tabs should be online: %+v", online)
	}

	// c1 also gets presence diff for c2's arrival.
	ev = waitForEvent(t, c1, "presence", time.Second)
	devs, _ = ev.Data.(map[string]any)["devices"].([]PresenceDevice)
	if len(devs) != 2 {
		t.Errorf("c1 presence after tab-2 arrival: got %d devices, want 2", len(devs))
	}
}

// A live device with no devices-table row (global-SSO browser / broker-auth desktop) and no
// device_id must still appear in presence as online, labelled by its declared name + type.
func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) {
	h := New(&fakeLister{}) // empty managed-device set
	defer h.Close()

	c := h.Connect(context.Background(), "alice", "", "macbook", "macos")
	defer h.Disconnect(c)

	ev := waitForEvent(t, c, "presence", time.Second)
	devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
	if len(devs) != 1 || devs[0].Name != "macbook" || devs[0].Type != "macos" || !devs[0].Online {
		t.Errorf("live-only device should appear online in presence: %+v", devs)
	}
}

func TestSendToRoutesEventByName(t *testing.T) {
	lister := &fakeLister{
		devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}},
	}
	h := New(lister)
	defer h.Close()

	c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
	defer h.Disconnect(c)
	_ = waitForEvent(t, c, "presence", time.Second)

	// Peer addressing is by name; the hub resolves it to the device_id-keyed client.
	if !h.SendTo("alice", "tab-1", Event{Type: "signal", Data: map[string]any{"x": 1}}) {
		t.Fatal("SendTo by name to a live client should report true")
	}
	ev := waitForEvent(t, c, "signal", time.Second)
	if ev.Data.(map[string]any)["x"] != 1 {
		t.Errorf("payload: %+v", ev.Data)
	}

	// Addressing by the stable device_id also works (direct map hit).
	if !h.SendTo("alice", "dev_1", Event{Type: "signal"}) {
		t.Fatal("SendTo by device_id should report true")
	}
	_ = waitForEvent(t, c, "signal", time.Second)
}

func TestSendToOfflineDeviceReportsFalse(t *testing.T) {
	h := New(&fakeLister{})
	defer h.Close()
	if h.SendTo("ghost-user", "ghost-device", Event{Type: "signal"}) {
		t.Error("SendTo to offline target should report false")
	}
}

// TestRenameKeepsEntryAndUpdatesRouting is the decoupling guarantee: a renamed device keeps
// its single hub entry (keyed by the stable device_id), so presence shows the new name with no
// duplicate and the device stays online and reachable by its NEW name — no reconnect, no token.
func TestRenameKeepsEntryAndUpdatesRouting(t *testing.T) {
	lister := &fakeLister{
		devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "iPhone", Type: "ios"}},
	}
	h := New(lister)
	defer h.Close()

	c := h.Connect(context.Background(), "alice", "dev_1", "iPhone", "ios")
	defer h.Disconnect(c)
	_ = waitForEvent(t, c, "presence", time.Second)

	// Simulate the rename: the DB row's name changes, then the hub is told.
	lister.devices[0].Name = "My iPhone"
	h.Rename("alice", "dev_1", "My iPhone")
	h.PublishPresence(context.Background(), "alice")

	ev := waitForEvent(t, c, "presence", time.Second)
	devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
	if len(devs) != 1 {
		t.Fatalf("rename must not duplicate the device: got %+v", devs)
	}
	if devs[0].Name != "My iPhone" || devs[0].DeviceID != "dev_1" || !devs[0].Online {
		t.Errorf("presence after rename: %+v", devs[0])
	}

	// Reachable by the new name; the old name no longer resolves.
	if !h.SendTo("alice", "My iPhone", Event{Type: "signal"}) {
		t.Error("renamed device should be reachable by its new name")
	}
	_ = waitForEvent(t, c, "signal", time.Second)
	if h.SendTo("alice", "iPhone", Event{Type: "signal"}) {
		t.Error("old name should no longer resolve after rename")
	}
}

func TestReconnectClosesOldChannel(t *testing.T) {
	h := New(&fakeLister{})
	defer h.Close()

	old := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
	// drain the initial presence so we can detect close
	<-old.Events()

	_ = h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")

	select {
	case _, ok := <-old.Events():
		if ok {
			t.Error("old channel should be closed on reconnect from same device_id")
		}
	case <-time.After(time.Second):
		t.Fatal("timeout waiting for old channel close")
	}
}

// A reconnect of the SAME device under a DIFFERENT name (e.g. mid-rename) still replaces its
// own entry rather than orphaning the old one — because the key is the stable device_id.
func TestReconnectAfterRenameReplacesSameEntry(t *testing.T) {
	h := New(&fakeLister{})
	defer h.Close()

	old := h.Connect(context.Background(), "alice", "dev_1", "iPhone", "ios")
	<-old.Events()

	_ = h.Connect(context.Background(), "alice", "dev_1", "My iPhone", "ios")

	select {
	case _, ok := <-old.Events():
		if ok {
			t.Error("old channel should be closed even when the name changed (same device_id)")
		}
	case <-time.After(time.Second):
		t.Fatal("timeout waiting for old channel close")
	}
}

func TestDisconnectRemovesFromOnlineSet(t *testing.T) {
	h := New(&fakeLister{
		devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}},
	})
	defer h.Close()

	c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
	if !h.Online("alice", "dev_1") {
		t.Fatal("client should be online after Connect")
	}
	h.Disconnect(c)
	if h.Online("alice", "dev_1") {
		t.Error("client should not be online after Disconnect")
	}
}

// TestConcurrentKickAndPresenceNoPanic hammers Kick (which closes channels under the write
// lock) against publishPresence / Broadcast / SendTo (non-blocking sends under the read lock).
// Before the close-vs-send fix this raced into a "send on closed channel" panic that crashed
// the whole process; now close and send are mutually exclusive. Run with -race.
func TestConcurrentKickAndPresenceNoPanic(t *testing.T) {
	h := New(&fakeLister{
		devices: []db.Device{{UserID: "u", DeviceID: "dev_0", Name: "d", Type: "browser"}},
	})
	defer h.Close()

	const workers = 16
	const iters = 200
	var wg sync.WaitGroup
	for i := 0; i < workers; i += 1 {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			id := fmt.Sprintf("dev_%d", n)
			name := fmt.Sprintf("d%d", n)
			for j := 0; j < iters; j += 1 {
				h.Connect(context.Background(), "u", id, name, "browser")
				h.PublishPresence(context.Background(), "u")
				h.Broadcast("u", Event{Type: "x"})
				h.SendTo("u", name, Event{Type: "y"})
				h.Rename("u", id, name+"-r")
				h.Kick("u", id, name)
			}
		}(i)
	}
	wg.Wait()
}

// TestGracePeriod uses a tiny grace so the test runs fast.
func TestGracePeriodDelaysOfflinePresence(t *testing.T) {
	h := New(&fakeLister{
		devices: []db.Device{
			{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"},
			{UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser"},
		},
	})
	h.grace = 100 * time.Millisecond
	defer h.Close()

	c1 := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
	defer h.Disconnect(c1)
	c2 := h.Connect(context.Background(), "alice", "dev_2", "tab-2", "browser")
	// Drain initial presence frames
	_ = waitForEvent(t, c1, "presence", time.Second)
	_ = waitForEvent(t, c1, "presence", time.Second)
	_ = waitForEvent(t, c2, "presence", time.Second)

	h.Disconnect(c2)
	// Grace 100ms: c1 should receive presence after the grace window with tab-2 offline.
	select {
	case ev := <-c1.Events():
		if ev.Type != "presence" {
			t.Fatalf("expected presence after grace, got %q", ev.Type)
		}
		devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
		var tab2Online bool
		for _, d := range devs {
			if d.Name == "tab-2" {
				tab2Online = d.Online
			}
		}
		if tab2Online {
			t.Error("tab-2 should be offline in post-grace presence")
		}
	case <-time.After(time.Second):
		t.Fatal("timeout waiting for post-grace presence")
	}
}
