// @vitest-environment happy-dom
//
// ConnectionContext's persisted orgConnection: hydration on boot, and the
// two places a stored remember-token can go stale (logout, and unticking
// "Remember me" on the resume path). No react-testing-library in this repo
// (see FileTree.statuses.test.jsx) — mount with react-dom/client directly and
// read state off a probe component.
//
// '../api/client' is mocked so login()/resume() never hit a real network —
// only the ConnectionContext bookkeeping around their results is under test
// here. '../platform' is mocked for the same reason client.test.js and
// fileCommand.test.js mock it: importing the real module pulls in Tauri
// bridge setup, and this suite wants direct control over (and visibility
// into) saveSecret/loadSecret/deleteSecret.
import { StrictMode, act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const state = vi.hoisted(() => ({
  loginResult: null,
  resumeResult: null,
  // How many times the stored token has actually been redeemed. Counted
  // because "resumed correctly" and "resumed once" are different claims.
  resumeCalls: 0,
  changePasswordResult: null,
  // login() probes /api/server and refuses an incompatible major before any
  // credential goes out, so every login test needs this to answer. Default to
  // the client's own version — the compatibility check is exercised on
  // purpose in its own describe block below.
  serverResult: null,
}));

// Only RcApi is stubbed; the version helpers alongside it are pure and are
// the very thing login() is being tested against, so keep the real ones.
vi.mock('../api/client', async (importOriginal) => {
  const actual = await importOriginal();
  class RcApi {
    constructor(ip) {
      this.baseUrl = ip;
      this.token = null;
      this.activeProjectId = null;
      this.onAuthExpired = null;
    }
    setActiveProject(id) {
      this.activeProjectId = id ?? null;
    }
    avatarUrl() {
      return null;
    }
    async getServer() {
      return state.serverResult;
    }
    async login() {
      return state.loginResult;
    }
    async resume() {
      state.resumeCalls += 1;
      return state.resumeResult;
    }
    logout() {}
    async listProjects() {
      return { ok: true, projects: [] };
    }
    async changePassword() {
      return state.changePasswordResult;
    }
  }
  return { ...actual, RcApi };
});

vi.mock('../platform', () => ({
  isTauri: false,
  deviceId: 'dev-1',
  getDeviceName: vi.fn(() => Promise.resolve('this-device')),
  fetchServerCertificate: vi.fn(() => Promise.reject(new Error('not used'))),
  saveSecret: vi.fn(() => Promise.resolve()),
  loadSecret: vi.fn(() => Promise.resolve(null)),
  deleteSecret: vi.fn(() => Promise.resolve()),
  loadWorkspacesNative: vi.fn(() => Promise.resolve(undefined)),
  saveWorkspacesNative: vi.fn(() => Promise.resolve(false)),
}));

import * as platform from '../platform';
import { CLIENT_VERSION } from '../api/client';
import { ConnectionProvider, useConnection } from './ConnectionContext';

const ORG_CONNECTION_KEY = 'cbm-org-connection-v1';

let container;
let root;
let ctx;

function Probe() {
  ctx = useConnection();
  return null;
}

function mount() {
  container = document.createElement('div');
  document.body.appendChild(container);
  root = createRoot(container);
  act(() => {
    root.render(
      <ConnectionProvider>
        <Probe />
      </ConnectionProvider>
    );
  });
}

beforeEach(() => {
  localStorage.clear();
  vi.clearAllMocks();
  state.loginResult = null;
  state.resumeResult = null;
  state.resumeCalls = 0;
  state.changePasswordResult = null;
  // A matching server, so tests about credential bookkeeping aren't all
  // stopped at the version gate.
  state.serverResult = { ok: true, version: CLIENT_VERSION };
  ctx = null;
});

afterEach(() => {
  act(() => root?.unmount());
  container?.remove();
});

describe('orgConnection hydration', () => {
  it('round-trips rememberExpiresAt through persist + re-hydrate, not just ip/username/remember', () => {
    const stored = {
      ip: 'localhost:8666',
      username: '@alice',
      remember: true,
      rememberExpiresAt: 1_893_456_000,
    };
    localStorage.setItem(ORG_CONNECTION_KEY, JSON.stringify(stored));

    mount();

    expect(ctx.orgConnection).toEqual(stored);
  });

  it('still drops unknown/legacy fields, e.g. an old stored password', () => {
    localStorage.setItem(
      ORG_CONNECTION_KEY,
      JSON.stringify({
        ip: 'localhost:8666',
        username: '@alice',
        remember: true,
        rememberExpiresAt: 1_893_456_000,
        password: 'legacy-plaintext-password',
      })
    );

    mount();

    expect(ctx.orgConnection).toEqual({
      ip: 'localhost:8666',
      username: '@alice',
      remember: true,
      rememberExpiresAt: 1_893_456_000,
    });
    expect(ctx.orgConnection).not.toHaveProperty('password');
  });
});

describe('logout', () => {
  it('clears the remembered countdown so the form cannot advertise a token that no longer exists', () => {
    const stored = {
      ip: 'localhost:8666',
      username: '@alice',
      remember: true,
      rememberExpiresAt: 1_893_456_000,
    };
    localStorage.setItem(ORG_CONNECTION_KEY, JSON.stringify(stored));
    mount();
    expect(ctx.orgConnection.rememberExpiresAt).toBe(1_893_456_000);

    act(() => {
      ctx.logout();
    });

    expect(ctx.orgConnection.rememberExpiresAt).toBeNull();
    expect(platform.deleteSecret).toHaveBeenCalledWith('localhost:8666|@alice');
  });

  it('persists the cleared countdown to localStorage, not just in-memory state, so it does not come back after a restart', () => {
    const stored = {
      ip: 'localhost:8666',
      username: '@alice',
      remember: true,
      rememberExpiresAt: 1_893_456_000,
    };
    localStorage.setItem(ORG_CONNECTION_KEY, JSON.stringify(stored));
    mount();

    act(() => {
      ctx.logout();
    });

    const persisted = JSON.parse(localStorage.getItem(ORG_CONNECTION_KEY));
    expect(persisted.rememberExpiresAt).toBeNull();
    // logout deletes the underlying credential outright, so a persisted
    // `remember: true` would still be lying about a token that no longer
    // exists -- clear it too, not just the countdown.
    expect(persisted.remember).toBe(false);
    // Whatever gets persisted must stay within the same four-key allowlist
    // the hydration initializer reads back (ip/username/remember/rememberExpiresAt).
    expect(Object.keys(persisted).sort()).toEqual(
      ['ip', 'remember', 'rememberExpiresAt', 'username'].sort()
    );
  });
});

describe('changePassword', () => {
  it('persists the cleared countdown to localStorage, not just in-memory state', async () => {
    const stored = {
      ip: 'localhost:8666',
      username: '@alice',
      remember: true,
      rememberExpiresAt: 1_893_456_000,
    };
    localStorage.setItem(ORG_CONNECTION_KEY, JSON.stringify(stored));
    mount();
    // Establish a session via a normal password login so changePassword's
    // `if (!session) return ...` guard doesn't short-circuit.
    state.loginResult = {
      ok: true,
      token: 'tok-1',
      rememberToken: 'rt-1',
      rememberExpiresAt: 1_893_456_000,
      user: { username: '@alice', displayName: 'Alice', devices: [] },
      org: { name: 'Org' },
    };
    await act(async () => {
      await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: 'secret',
        remember: true,
      });
    });
    expect(ctx.orgConnection.rememberExpiresAt).toBe(1_893_456_000);

    state.changePasswordResult = { ok: true };
    await act(async () => {
      await ctx.changePassword('secret', 'newsecret');
    });

    expect(ctx.orgConnection.rememberExpiresAt).toBeNull();
    const persisted = JSON.parse(localStorage.getItem(ORG_CONNECTION_KEY));
    expect(persisted.rememberExpiresAt).toBeNull();
    expect(Object.keys(persisted).sort()).toEqual(
      ['ip', 'remember', 'rememberExpiresAt', 'username'].sort()
    );
  });
});

describe('resuming from a stored token', () => {
  it('honors an unticked "Remember me": forgets the token instead of resuming remembered', async () => {
    mount();
    state.resumeResult = {
      ok: true,
      token: 'tok-resumed',
      user: { username: '@alice', displayName: 'Alice', devices: [] },
      org: { name: 'Org' },
    };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: '',
        remember: false,
      });
    });

    expect(result.ok).toBe(true);
    expect(platform.deleteSecret).toHaveBeenCalledWith('localhost:8666|@alice');
    expect(ctx.orgConnection.remember).toBe(false);
    expect(ctx.orgConnection.rememberExpiresAt).toBeNull();
  });

  it('still resumes remembered when the checkbox stays ticked', async () => {
    mount();
    state.resumeResult = {
      ok: true,
      token: 'tok-resumed',
      user: { username: '@alice', displayName: 'Alice', devices: [] },
      org: { name: 'Org' },
    };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: '',
        remember: true,
      });
    });

    expect(result.ok).toBe(true);
    expect(platform.deleteSecret).not.toHaveBeenCalled();
    expect(ctx.orgConnection.remember).toBe(true);
  });

  it('survives a restart: seeded localStorage rememberExpiresAt is preserved through a resume login in both state and localStorage', async () => {
    // Simulates: app restart (fresh mount hydrates from localStorage) ->
    // resume-login (whose response never carries rememberExpiresAt, because
    // /api/session/resume deliberately doesn't reissue/reset it) -> the
    // existing countdown must still be there afterward, in both places.
    const stored = {
      ip: 'localhost:8666',
      username: '@alice',
      remember: true,
      rememberExpiresAt: 1_893_456_000,
    };
    localStorage.setItem(ORG_CONNECTION_KEY, JSON.stringify(stored));
    mount();

    state.resumeResult = {
      ok: true,
      token: 'tok-resumed',
      user: { username: '@alice', displayName: 'Alice', devices: [] },
      org: { name: 'Org' },
      // deliberately no rememberExpiresAt here
    };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: '',
        remember: true,
      });
    });

    expect(result.ok).toBe(true);
    expect(ctx.orgConnection.rememberExpiresAt).toBe(1_893_456_000);
    const persisted = JSON.parse(localStorage.getItem(ORG_CONNECTION_KEY));
    expect(persisted.rememberExpiresAt).toBe(1_893_456_000);
  });

  // RcApi.request normalizes EVERY failure to {ok:false, ...} — a network
  // failure has no `status` at all, a 429 has status:429, a pin mismatch has
  // pinMismatch:true and no status. Only a 401 means the server actually
  // rejected the token; that's the ONLY case that should delete it (the JS
  // twin of cambium-core/src/auth.rs's `an_unreachable_server_keeps_the_
  // remember_token` / `a_server_error_on_resume_keeps_the_remember_token`).
  it('a network failure (no status) leaves the stored token in place and reports it honestly, not as "expired"', async () => {
    mount();
    state.resumeResult = { ok: false, error: 'Could not reach the server. Is it running?' };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: '',
        remember: true,
      });
    });

    expect(result.ok).toBe(false);
    expect(platform.deleteSecret).not.toHaveBeenCalled();
    expect(result.error).not.toMatch(/expired/i);
  });

  it('a 429 (rate limited) leaves the stored token in place and does not claim it expired', async () => {
    mount();
    state.resumeResult = { ok: false, status: 429, error: 'Too many attempts. Try again shortly.' };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: '',
        remember: true,
      });
    });

    expect(result.ok).toBe(false);
    expect(platform.deleteSecret).not.toHaveBeenCalled();
    expect(result.error).not.toMatch(/expired/i);
  });

  it('a genuine 401 rejection deletes the stored token and reports it as expired', async () => {
    mount();
    state.resumeResult = { ok: false, status: 401, error: 'Your saved sign-in is no longer valid. Log in again.' };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: '',
        remember: true,
      });
    });

    expect(result.ok).toBe(false);
    expect(platform.deleteSecret).toHaveBeenCalledWith('localhost:8666|@alice');
    expect(result.error).toMatch(/expired/i);
  });

  it('a pin mismatch on resume triggers the hard-stop mismatch modal instead of an inline "expired" message, and does not delete the token', async () => {
    mount();
    state.resumeResult = { ok: false, pinMismatch: true, error: 'Server identity changed — connection refused.' };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: '',
        remember: true,
      });
    });

    expect(result.ok).toBe(false);
    expect(platform.deleteSecret).not.toHaveBeenCalled();
    expect(ctx.pinMismatch).toBeTruthy();
  });
});

describe('startup silent resume', () => {
  // Opening the app with a remembered connection redeems the stored
  // device-bound token before any form is shown. Every way that can fail has
  // to end the same way: `startupPending` false (so the UI stops waiting) and
  // a reason on `startupError` for the Login page to show.
  const REMEMBERED = {
    ip: 'localhost:8666',
    username: '@alice',
    remember: true,
    rememberExpiresAt: 1_893_456_000,
  };

  const remembered = (overrides) =>
    localStorage.setItem(ORG_CONNECTION_KEY, JSON.stringify({ ...REMEMBERED, ...overrides }));

  // The boot effect chains loadSecret -> getServer -> resume -> listProjects,
  // each its own await, so one act() pass isn't enough to drain it.
  async function settle() {
    for (let i = 0; i < 6; i += 1) {
      await act(async () => {});
    }
  }

  it('redeems the token exactly once under StrictMode, which runs effects twice', async () => {
    // Redeeming is a server-side act — it registers a device against the
    // account's limit — so the development double-invoke must not reach the
    // network twice. Guarding only the state updates is not enough: the first
    // call is already away by the time the cleanup runs. This suite's own
    // mount() is not StrictMode, so the guard needs its own mount to be seen
    // failing at all.
    remembered();
    platform.loadSecret.mockResolvedValue('rt-stored');
    state.resumeResult = {
      ok: true,
      token: 'tok-resumed',
      user: { username: '@alice', displayName: 'Alice', devices: [] },
      org: { name: 'Org' },
    };

    container = document.createElement('div');
    document.body.appendChild(container);
    root = createRoot(container);
    act(() => {
      root.render(
        <StrictMode>
          <ConnectionProvider>
            <Probe />
          </ConnectionProvider>
        </StrictMode>
      );
    });
    await settle();

    expect(state.resumeCalls).toBe(1);
    expect(ctx.loggedIn).toBe(true);
    expect(ctx.startupPending).toBe(false);
  });

  it('resumes with nothing typed, so the app opens straight into the session', async () => {
    remembered();
    platform.loadSecret.mockResolvedValue('rt-stored');
    state.resumeResult = {
      ok: true,
      token: 'tok-resumed',
      user: { username: '@alice', displayName: 'Alice', devices: [] },
      org: { name: 'Org' },
    };

    mount();
    expect(ctx.startupPending).toBe(true);
    await settle();

    expect(ctx.loggedIn).toBe(true);
    expect(ctx.startupPending).toBe(false);
    expect(ctx.startupError).toBeNull();
    expect(platform.loadSecret).toHaveBeenCalledWith('localhost:8666|@alice');
  });

  it('does not attempt anything when the connection was never remembered', async () => {
    remembered({ remember: false, rememberExpiresAt: null });

    mount();
    // Nothing to wait for: the UI must be free to present Login immediately.
    expect(ctx.startupPending).toBe(false);
    await settle();

    expect(platform.loadSecret).not.toHaveBeenCalled();
    expect(ctx.startupError).toBeNull();
  });

  it('stays quiet when `remember` is set but no token is actually stored', async () => {
    // e.g. the credential store was cleared behind the app's back. There is
    // nothing to resume, which is not a failure — going to the login form
    // with "Enter your password." on it would be an error about a form the
    // user was never shown.
    remembered();
    platform.loadSecret.mockResolvedValue(null);

    mount();
    await settle();

    expect(ctx.loggedIn).toBe(false);
    expect(ctx.startupPending).toBe(false);
    expect(ctx.startupError).toBeNull();
  });

  it('falls back to the login form with the reason when the token is rejected, and forgets it', async () => {
    remembered();
    platform.loadSecret.mockResolvedValue('rt-stored');
    state.resumeResult = { ok: false, status: 401, error: 'Your saved sign-in is no longer valid. Log in again.' };

    mount();
    await settle();

    expect(ctx.loggedIn).toBe(false);
    expect(ctx.startupPending).toBe(false);
    expect(ctx.startupError).toMatch(/expired/i);
    expect(platform.deleteSecret).toHaveBeenCalledWith('localhost:8666|@alice');
  });

  it('reports an unreachable server honestly and keeps the token for the next attempt', async () => {
    remembered();
    platform.loadSecret.mockResolvedValue('rt-stored');
    state.resumeResult = { ok: false, error: 'Could not reach the server. Is it running?' };

    mount();
    await settle();

    expect(ctx.startupPending).toBe(false);
    expect(ctx.startupError).toMatch(/Could not reach the server/);
    expect(ctx.startupError).not.toMatch(/expired/i);
    expect(platform.deleteSecret).not.toHaveBeenCalled();
  });

  it('reports an incompatible server as a version problem, without sending the token', async () => {
    remembered();
    platform.loadSecret.mockResolvedValue('rt-stored');
    state.serverResult = { ok: true, version: '99.0.0' };

    mount();
    await settle();

    expect(ctx.startupPending).toBe(false);
    expect(ctx.startupError).toMatch(/newer than this client/);
  });

  it('raises the hard-stop mismatch modal on a changed server identity, without judging the token', async () => {
    remembered();
    platform.loadSecret.mockResolvedValue('rt-stored');
    state.resumeResult = { ok: false, pinMismatch: true, error: 'Server identity changed — connection refused.' };

    mount();
    await settle();

    expect(ctx.startupPending).toBe(false);
    expect(ctx.pinMismatch).toBeTruthy();
    // The mismatch modal is the stop; whatever reaches the form alongside it
    // must not claim the token expired, because it was never judged.
    expect(ctx.startupError).not.toMatch(/expired/i);
    expect(platform.deleteSecret).not.toHaveBeenCalled();
  });

  it('gives up waiting on a server that never answers, rather than holding the app on a spinner', async () => {
    remembered();
    platform.loadSecret.mockResolvedValue('rt-stored');
    // Nothing in the API client bounds a request; a host that swallows
    // packets never resolves at all.
    state.resumeResult = new Promise(() => {});
    vi.useFakeTimers();
    try {
      mount();
      await settle();
      expect(ctx.startupPending).toBe(true);

      await act(async () => {
        vi.advanceTimersByTime(10_000);
      });

      expect(ctx.startupPending).toBe(false);
      expect(ctx.startupError).toMatch(/taking too long/i);
    } finally {
      vi.useRealTimers();
    }
  });

  it('clears the boot-time error once the user attempts a login themselves', async () => {
    remembered();
    platform.loadSecret.mockResolvedValue('rt-stored');
    state.resumeResult = { ok: false, status: 401, error: 'Your saved sign-in is no longer valid. Log in again.' };

    mount();
    await settle();
    expect(ctx.startupError).toMatch(/expired/i);

    state.loginResult = {
      ok: true,
      token: 'tok-1',
      user: { username: '@alice', displayName: 'Alice', devices: [] },
      org: { name: 'Org' },
    };
    await act(async () => {
      await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: 'secret',
        remember: true,
      });
    });

    // Otherwise logging out and reopening Login would still be showing it.
    expect(ctx.startupError).toBeNull();
  });
});

describe('onAuthExpired (silent resume after a 401, wired in finishLogin)', () => {
  // Establishes a real session (via password login) so `ctx.session.api` is
  // the live RcApi instance with onAuthExpired wired onto it, then drives
  // that hook directly the way client.js's _retryAfter401 would.
  async function loginAndGetApi() {
    mount();
    state.loginResult = {
      ok: true,
      token: 'tok-1',
      rememberToken: 'rt-1',
      rememberExpiresAt: 1_893_456_000,
      user: { username: '@alice', displayName: 'Alice', devices: [] },
      org: { name: 'Org' },
    };
    await act(async () => {
      await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: 'secret',
        remember: true,
      });
    });
    return ctx.api;
  }

  it('deletes the stored token only when the resume it triggers comes back 401', async () => {
    const api = await loginAndGetApi();
    state.resumeResult = { ok: false, status: 401, error: 'Your saved sign-in is no longer valid. Log in again.' };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let newToken;
    await act(async () => {
      newToken = await api.onAuthExpired();
    });

    expect(newToken).toBeFalsy();
    expect(platform.deleteSecret).toHaveBeenCalledWith('localhost:8666|@alice');
  });

  it('a network failure (no status) during the silent resume leaves the stored token in place', async () => {
    const api = await loginAndGetApi();
    state.resumeResult = { ok: false, error: 'Could not reach the server. Is it running?' };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let newToken;
    await act(async () => {
      newToken = await api.onAuthExpired();
    });

    expect(newToken).toBeFalsy();
    expect(platform.deleteSecret).not.toHaveBeenCalled();
  });

  it('a 429 during the silent resume leaves the stored token in place', async () => {
    const api = await loginAndGetApi();
    state.resumeResult = { ok: false, status: 429, error: 'Too many attempts. Try again shortly.' };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let newToken;
    await act(async () => {
      newToken = await api.onAuthExpired();
    });

    expect(newToken).toBeFalsy();
    expect(platform.deleteSecret).not.toHaveBeenCalled();
  });

  it('returns the fresh token on a successful resume, for the caller to retry the original request with', async () => {
    const api = await loginAndGetApi();
    state.resumeResult = { ok: true, token: 'tok-resumed-2', user: { username: '@alice' } };
    platform.loadSecret.mockResolvedValueOnce('rt-stored');

    let newToken;
    await act(async () => {
      newToken = await api.onAuthExpired();
    });

    expect(newToken).toBe('tok-resumed-2');
    expect(platform.deleteSecret).not.toHaveBeenCalled();
  });
});

describe('server version compatibility', () => {
  // login() probes /api/server before it sends anything, so a major mismatch
  // is refused without a credential ever leaving this machine. The server
  // enforces the same rule from its side (426); this is the half that gives
  // the user a message naming which side to upgrade.
  it('refuses an incompatible server and never calls login()', async () => {
    mount();
    let loginCalled = false;
    state.serverResult = { ok: true, version: '99.0.0' };
    state.loginResult = new Proxy({ ok: true, token: 'nope' }, {
      get(target, prop) {
        loginCalled = true;
        return target[prop];
      },
    });

    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: 'secret',
        remember: true,
      });
    });

    expect(result.ok).toBe(false);
    expect(result.error).toMatch(/newer than this client/);
    expect(loginCalled).toBe(false);
    // No session, and nothing persisted for a server we refused to talk to.
    expect(ctx.session).toBeFalsy();
    expect(platform.saveSecret).not.toHaveBeenCalled();
  });

  it('connects normally when the majors match', async () => {
    mount();
    state.serverResult = { ok: true, version: CLIENT_VERSION };
    state.loginResult = {
      ok: true,
      token: 'tok-1',
      user: { username: '@alice', displayName: 'Alice', devices: [] },
      org: { name: 'Org' },
    };
    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: 'secret',
        remember: false,
      });
    });
    expect(result.ok).toBe(true);
  });

  it('reports an unreachable server as unreachable, not as a version problem', async () => {
    mount();
    state.serverResult = { ok: false, error: 'Could not reach the server. Is it running?' };
    let result;
    await act(async () => {
      result = await ctx.login({
        ip: 'localhost:8666',
        username: '@alice',
        password: 'secret',
        remember: false,
      });
    });
    expect(result.ok).toBe(false);
    expect(result.error).toMatch(/Could not reach the server/);
  });
});

describe('loggedIn vs projectReady on a server with no projects', () => {
  // These two used to be one flag called `connected`, defined as "logged in
  // AND a project is active". Server-scoped consumers read it as "connected
  // to a server", which hid Administration on a project-less server — and
  // creating the first project lives inside Administration.
  const loginAsOwner = async () => {
    state.loginResult = {
      ok: true,
      token: 'tok-1',
      user: { username: '@owner', displayName: 'Owner', role: 'owner', devices: [] },
      org: { name: 'Org', organizationEnabled: true },
    };
    mount();
    await act(async () => {
      // The stubbed RcApi.listProjects answers `projects: []`, which is the
      // freshly-created-server case this whole block is about.
      await ctx.login({
        ip: 'localhost:8666',
        username: '@owner',
        password: 'pw',
        remember: false,
      });
    });
  };

  it('reports a live session even though no project is active', async () => {
    await loginAsOwner();
    expect(ctx.loggedIn).toBe(true);
    expect(ctx.projectReady).toBe(false);
    expect(ctx.activeProject).toBeNull();
  });

  it('exposes the owner role, so the Administration gate can pass', async () => {
    await loginAsOwner();
    expect(ctx.account?.role).toBe('owner');
  });

  it('no longer exposes the ambiguous `connected` flag', async () => {
    await loginAsOwner();
    // A consumer still reading `connected` would silently get undefined —
    // falsy — and re-introduce exactly the bug this replaced.
    expect(ctx).not.toHaveProperty('connected');
  });

  it('drops both flags on logout', async () => {
    await loginAsOwner();
    act(() => ctx.logout());
    expect(ctx.loggedIn).toBe(false);
    expect(ctx.projectReady).toBe(false);
  });
});
