import { describe, it, expect, beforeEach, vi } from "vitest";
import {
  authStore,
  setAuth,
  clearAuth,
  getToken,
  getCurrentUser,
  updateUser,
} from "../../src/stores/auth.store";
import {
  voiceStore,
  resetVoiceStore,
  joinVoiceChannel,
  setVoiceStatus,
} from "../../src/stores/voice.store";
import { leaveVoice } from "@lib/livekitSession";
import { setMessages, isChannelLoaded, getChannelMessages } from "../../src/stores/messages.store";
import { channelsStore, setChannels } from "../../src/stores/channels.store";
import type { ReadyChannel } from "../../src/lib/types";
import { acknowledgeNsfw, isNsfwAcknowledged } from "../../src/lib/nsfw-gate";
import { addLogListener, type LogEntry } from "@lib/logger";
import type { UserWithRole, MessageResponse, MessageUser } from "../../src/lib/types";
import { uiStore, setSidebarMode, setActiveDmUser } from "../../src/stores/ui.store";

// Mock the lazily-imported voice SDK module so we can assert clearAuth() only
// pulls it in (loading the ~1.3 MB LiveKit chunk) when a voice session exists.
vi.mock("@lib/livekitSession", () => ({
  leaveVoice: vi.fn(),
}));

const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0));

const TEST_USER: UserWithRole = {
  id: 42,
  username: "testuser",
  avatar: "avatar.png",
  role: "member",
};

const TEST_TOKEN = "session-token-abc123";
const TEST_SERVER_NAME = "My OwnCord Server";
const TEST_MOTD = "Welcome to OwnCord!";

function resetStore(): void {
  clearAuth();
}

describe("auth store", () => {
  beforeEach(() => {
    resetStore();
  });

  // 1. Initial state is unauthenticated
  describe("initial state", () => {
    it("has null token", () => {
      expect(authStore.getState().token).toBeNull();
    });

    it("has null user", () => {
      expect(authStore.getState().user).toBeNull();
    });

    it("has null serverName", () => {
      expect(authStore.getState().serverName).toBeNull();
    });

    it("has null motd", () => {
      expect(authStore.getState().motd).toBeNull();
    });

    it("is not authenticated", () => {
      expect(authStore.getState().isAuthenticated).toBe(false);
    });
  });

  // 2. setAuth populates all fields correctly
  describe("setAuth", () => {
    it("sets token", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(authStore.getState().token).toBe(TEST_TOKEN);
    });

    it("sets user", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(authStore.getState().user).toEqual(TEST_USER);
    });

    it("sets serverName", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(authStore.getState().serverName).toBe(TEST_SERVER_NAME);
    });

    it("sets motd", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(authStore.getState().motd).toBe(TEST_MOTD);
    });

    it("sets isAuthenticated to true", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(authStore.getState().isAuthenticated).toBe(true);
    });

    it("returns a new state object on each call", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      const first = authStore.getState();
      setAuth("other-token", TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      const second = authStore.getState();
      expect(first).not.toBe(second);
    });
  });

  // 3. clearAuth resets to initial state
  describe("clearAuth", () => {
    it("resets all fields after being authenticated", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      clearAuth();

      const state = authStore.getState();
      expect(state.token).toBeNull();
      expect(state.user).toBeNull();
      expect(state.serverName).toBeNull();
      expect(state.motd).toBeNull();
      expect(state.isAuthenticated).toBe(false);
    });

    it("produces a new state object", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      const before = authStore.getState();
      clearAuth();
      const after = authStore.getState();
      expect(before).not.toBe(after);
    });

    // v076: acknowledgements are per-viewer consent, not per-device. Host
    // scoping cannot cover a second account on the SAME server, so the age
    // gate must be re-armed on logout or the next user silently inherits it.
    it("clears NSFW acknowledgements so the next account re-sees the age gate", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      acknowledgeNsfw(12);
      expect(isNsfwAcknowledged(12)).toBe(true);

      clearAuth();

      expect(isNsfwAcknowledged(12)).toBe(false);
    });

    it("records 'user' as the default logout reason", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      clearAuth();
      expect(authStore.getState().logoutReason).toBe("user");
    });

    it("records an explicit logout reason and resets it on the next setAuth", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      clearAuth("server_shutdown");
      expect(authStore.getState().logoutReason).toBe("server_shutdown");

      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(authStore.getState().logoutReason).toBeUndefined();
    });
  });

  // 4. getToken returns current token
  describe("getToken", () => {
    it("returns null when unauthenticated", () => {
      expect(getToken()).toBeNull();
    });

    it("returns token after setAuth", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(getToken()).toBe(TEST_TOKEN);
    });

    it("returns null after clearAuth", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      clearAuth();
      expect(getToken()).toBeNull();
    });
  });

  // 5. updateUser patches user fields
  describe("updateUser", () => {
    it("updates username on authenticated user", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      updateUser({ username: "newname" });
      expect(authStore.getState().user?.username).toBe("newname");
    });

    it("preserves other user fields when patching", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      updateUser({ username: "newname" });
      const user = authStore.getState().user;
      expect(user?.id).toBe(42);
      expect(user?.avatar).toBe("avatar.png");
      expect(user?.role).toBe("member");
    });

    it("is a no-op when user is null", () => {
      updateUser({ username: "newname" });
      expect(authStore.getState().user).toBeNull();
    });

    it("produces a new state object", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      const before = authStore.getState();
      updateUser({ username: "changed" });
      expect(authStore.getState()).not.toBe(before);
    });

    it("produces a new user object (immutable)", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      const userBefore = authStore.getState().user;
      updateUser({ avatar: "new-avatar.png" });
      const userAfter = authStore.getState().user;
      expect(userBefore).not.toBe(userAfter);
      expect(userAfter?.avatar).toBe("new-avatar.png");
    });

    it("sets totp_enabled to true", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      updateUser({ totp_enabled: true });
      expect(authStore.getState().user?.totp_enabled).toBe(true);
    });

    it("sets totp_enabled to false", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      updateUser({ totp_enabled: true });
      updateUser({ totp_enabled: false });
      expect(authStore.getState().user?.totp_enabled).toBe(false);
    });

    it("initial user has no totp_enabled (undefined)", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(authStore.getState().user?.totp_enabled).toBeUndefined();
    });
  });

  // 6. getCurrentUser returns current user
  describe("getCurrentUser", () => {
    it("returns null when unauthenticated", () => {
      expect(getCurrentUser()).toBeNull();
    });

    it("returns user after setAuth", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(getCurrentUser()).toEqual(TEST_USER);
    });

    it("returns null after clearAuth", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      clearAuth();
      expect(getCurrentUser()).toBeNull();
    });
  });

  // clearAuth voice-session cleanup (regression: don't force-load the LiveKit
  // chunk on every logout/401 for a text-only user).
  describe("clearAuth voice cleanup", () => {
    beforeEach(() => {
      resetVoiceStore();
      vi.mocked(leaveVoice).mockClear();
    });

    it("does NOT load livekitSession when there is no active voice session", async () => {
      // Voice store is idle (currentChannelId null, voiceStatus "idle").
      clearAuth();
      await flushMicrotasks();
      expect(leaveVoice).not.toHaveBeenCalled();
    });

    it("leaves voice when a voice session is active", async () => {
      joinVoiceChannel(7); // currentChannelId=7, voiceStatus="joining"
      setVoiceStatus("connected");
      clearAuth();
      await flushMicrotasks();
      expect(leaveVoice).toHaveBeenCalledWith(false);
    });

    // Boundary: a channel id can outlive the status settling back to idle
    // (e.g. a leave that updated voiceStatus but hasn't cleared
    // currentChannelId yet). clearAuth's guard is an AND of both conditions,
    // not just "was a channel ever joined" — this pins that a set channel id
    // alone must NOT trigger another leaveVoice call once already idle.
    it("does NOT load livekitSession when the channel id is set but status is already idle", async () => {
      joinVoiceChannel(7);
      setVoiceStatus("idle");
      clearAuth();
      await flushMicrotasks();
      expect(leaveVoice).not.toHaveBeenCalled();
    });

    // Boundary: the inverse — a non-idle status alone (no channel id) must
    // also NOT trigger leaveVoice. Together with the case above, this pins
    // that clearAuth requires BOTH currentChannelId set AND status !== idle,
    // not either one alone.
    it("does NOT load livekitSession when status is non-idle but no channel id is set", async () => {
      setVoiceStatus("reconnecting");
      clearAuth();
      await flushMicrotasks();
      expect(leaveVoice).not.toHaveBeenCalled();
    });

    it("logs a warning tagged with this module's component name when leaveVoice rejects", async () => {
      vi.spyOn(console, "warn").mockImplementation(() => {});
      const entries: LogEntry[] = [];
      const unsub = addLogListener((e) => entries.push(e));
      vi.mocked(leaveVoice).mockRejectedValueOnce(new Error("boom"));

      joinVoiceChannel(7);
      setVoiceStatus("connected");
      clearAuth();
      await flushMicrotasks();
      unsub();

      const warnEntry = entries.find((e) => e.level === "warn");
      expect(warnEntry?.component).toBe("auth.store");
      expect(warnEntry?.message).toBe("Failed to leave voice session during clearAuth");
    });
  });

  // clearAuth's logoutWasInVoice snapshot — main.ts's isAuthenticated
  // subscriber gates its voice_leave send on this instead of re-reading
  // voiceStore, which clearAuth has already reset by the time any subscriber
  // observes the transition (store notifications are microtask-deferred).
  describe("clearAuth logoutWasInVoice snapshot", () => {
    beforeEach(() => {
      resetVoiceStore();
    });

    it("is false when not in a voice channel at logout", () => {
      clearAuth();
      expect(authStore.getState().logoutWasInVoice).toBe(false);
    });

    it("snapshots true when in a voice channel, surviving clearAuth's own voiceStore reset", () => {
      joinVoiceChannel(7);
      clearAuth();

      expect(authStore.getState().logoutWasInVoice).toBe(true);
      // The snapshot must reflect voice state as it was BEFORE this same
      // call reset it — not the (already-idle) state read afterward.
      expect(voiceStore.getState().currentChannelId).toBeNull();
    });
  });

  // 6. Subscribe receives updates on setAuth/clearAuth
  describe("subscribe", () => {
    it("notifies on setAuth", () => {
      const listener = vi.fn();
      const unsub = authStore.subscribe(listener);

      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      authStore.flush();

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith(
        expect.objectContaining({
          token: TEST_TOKEN,
          user: TEST_USER,
          serverName: TEST_SERVER_NAME,
          motd: TEST_MOTD,
          isAuthenticated: true,
        }),
      );

      unsub();
    });

    it("notifies on clearAuth", () => {
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);

      const listener = vi.fn();
      const unsub = authStore.subscribe(listener);

      clearAuth();
      authStore.flush();

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith(
        expect.objectContaining({
          token: null,
          user: null,
          serverName: null,
          motd: null,
          isAuthenticated: false,
        }),
      );

      unsub();
    });

    it("does not notify after unsubscribe", () => {
      const listener = vi.fn();
      const unsub = authStore.subscribe(listener);
      unsub();

      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      expect(listener).not.toHaveBeenCalled();
    });

    it("notifies multiple subscribers independently", () => {
      const listenerA = vi.fn();
      const listenerB = vi.fn();
      const unsubA = authStore.subscribe(listenerA);
      const unsubB = authStore.subscribe(listenerB);

      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      authStore.flush();

      expect(listenerA).toHaveBeenCalledTimes(1);
      expect(listenerB).toHaveBeenCalledTimes(1);

      unsubA();
      unsubB();
    });
  });

  // Regression: clearAuth() must also drop messagesStore, or a channel id
  // that also exists on the next-signed-into server (channel ids are only
  // unique per-server) renders the previous session's cached messages and
  // never refetches, because MessageController.loadMessages short-circuits
  // on isChannelLoaded.
  describe("clearAuth messages cleanup", () => {
    const AUTHOR: MessageUser = { id: 1, username: "alice", avatar: "alice.png" };

    function makeMessageResponse(overrides?: Partial<MessageResponse>): MessageResponse {
      return {
        id: 1,
        channel_id: 1,
        user: AUTHOR,
        content: "pre-logout message",
        reply_to: null,
        attachments: [],
        reactions: [],
        pinned: false,
        edited_at: null,
        deleted: false,
        timestamp: "2026-03-15T10:00:00Z",
        ...overrides,
      };
    }

    it("clears cached messages and the loaded flag on logout", () => {
      setMessages(1, [makeMessageResponse()], false);
      expect(isChannelLoaded(1)).toBe(true);
      expect(getChannelMessages(1)).toHaveLength(1);

      clearAuth();

      expect(isChannelLoaded(1)).toBe(false);
      expect(getChannelMessages(1)).toHaveLength(0);
    });

    it("does not leak the previous session's message content into the next", () => {
      setMessages(1, [makeMessageResponse({ content: "server A secret" })], false);
      clearAuth();
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);

      // Same numeric channel id, different server: must come back empty and
      // unloaded so the caller refetches instead of rendering stale content.
      expect(isChannelLoaded(1)).toBe(false);
      expect(getChannelMessages(1)).toHaveLength(0);
    });
  });

  // Regression: clearAuth() must also drop channelsStore, or setChannels'
  // DM-row carry (a DM channel row is deliberately preserved across a normal
  // `ready` rebuild, since ready never restates DM rows) re-inserts the
  // PREVIOUS server's DM channel ids into the NEXT server's channel map on
  // the next login — a stale phantom channel signed into an unrelated server.
  describe("clearAuth channels cleanup", () => {
    const readyChannels: ReadyChannel[] = [
      { id: 1, name: "general", type: "text", category: "Text", position: 0, unread_count: 3 },
    ];

    it("clears channels, activeChannelId, and roles on logout", () => {
      setChannels(readyChannels);
      expect(channelsStore.getState().channels.size).toBe(1);

      clearAuth();

      expect(channelsStore.getState().channels.size).toBe(0);
      expect(channelsStore.getState().activeChannelId).toBeNull();
    });

    it("does not carry the previous server's DM channel row into the next session", () => {
      setChannels(readyChannels);
      // Synthesize a DM row the way addDmToChannelsStore does — setChannels'
      // carry loop (channels.store.ts) re-inserts any "dm"-typed row across
      // every future setChannels call unless the store is reset first.
      channelsStore.setState((prev) => {
        const next = new Map(prev.channels);
        next.set(999, {
          id: 999,
          name: "alice",
          type: "dm",
          category: null,
          topic: "",
          position: 0,
          unreadCount: 0,
          mentionCount: 0,
          lastMessageId: null,
          canSend: true,
          slowMode: 0,
          nsfw: false,
          voiceMaxUsers: 0,
          voiceMaxVideo: 0,
        });
        return { ...prev, channels: next };
      });
      expect(channelsStore.getState().channels.has(999)).toBe(true);

      clearAuth();
      setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD);
      setChannels([]); // the next server's `ready` — no DMs of its own yet

      expect(channelsStore.getState().channels.has(999)).toBe(false);
    });
  });

  // Regression: clearAuth() must also reset uiStore's sidebarMode and
  // activeDmUserId, or a "dms" sidebar mode (and the previous server's DM
  // peer id) survive a logout as module-global state and leak into the next
  // signed-into server — SidebarArea.ts reads uiStore.getState().sidebarMode
  // on initial mount, so the next server mounts the DM sidebar instead of its
  // channel list even though nothing restated sidebarMode for the new session.
  describe("clearAuth ui cleanup", () => {
    it("resets sidebarMode to 'channels' and clears activeDmUserId on logout", () => {
      setSidebarMode("dms");
      setActiveDmUser(7);
      expect(uiStore.getState().sidebarMode).toBe("dms");
      expect(uiStore.getState().activeDmUserId).toBe(7);

      clearAuth();

      expect(uiStore.getState().sidebarMode).toBe("channels");
      expect(uiStore.getState().activeDmUserId).toBeNull();
    });
  });
});
