// @vitest-environment happy-dom
//
// client.js's request() goes through platform.apiFetch, which (outside
// Tauri) falls back to window.fetch — see platform.js's `apiFetch` export.
// happy-dom is needed so `window` exists for that fallback to resolve
// against, same reason serverPins.test.js and credentialAccount.test.js
// scope a DOM to themselves rather than switching the global environment.
import { describe, expect, it, vi } from 'vitest';
import {
  RcApi,
  CLIENT_VERSION,
  CLIENT_VERSION_HEADER,
  majorOf,
  isCompatible,
  incompatibilityMessage,
} from './client';

describe('RcApi.resume', () => {
  it('posts the remember token and adopts the session token', async () => {
    const fetchMock = vi.fn().mockResolvedValue({
      ok: true, status: 200,
      json: async () => ({ token: 'tok-resumed', user: { username: '@gina' } }),
    });
    vi.stubGlobal('fetch', fetchMock);
    const api = new RcApi('localhost:8666');
    const result = await api.resume({
      username: '@gina', rememberToken: 'rt-1', deviceId: 'dev-1',
    });
    expect(result.ok).toBe(true);
    expect(api.token).toBe('tok-resumed');
    const [, options] = fetchMock.mock.calls[0];
    expect(JSON.parse(options.body)).toMatchObject({
      username: '@gina', rememberToken: 'rt-1', deviceId: 'dev-1',
    });
    expect(JSON.parse(options.body)).not.toHaveProperty('password');
    vi.unstubAllGlobals();
  });

  it('does not recurse when the token onAuthExpired resumes with is itself dead', async () => {
    // Every response is a 401 — simulating a dead remember-token, which the
    // server also answers with 401 (expired / wrong device / unknown user /
    // wrong token are deliberately collapsed into one response). A safety
    // cap stands in for the server's real login rate-limiter: if the fix
    // regresses, this throws instead of looping forever inside the test.
    let callCount = 0;
    const fetchMock = vi.fn(async () => {
      callCount += 1;
      if (callCount > 10) {
        throw new Error('runaway retry recursion — safety cap tripped');
      }
      return {
        ok: false, status: 401,
        json: async () => ({ error: 'Unauthorized' }),
      };
    });
    vi.stubGlobal('fetch', fetchMock);

    const api = new RcApi('localhost:8666');
    let onAuthExpiredCalls = 0;
    api.onAuthExpired = async () => {
      onAuthExpiredCalls += 1;
      const resumed = await api.resume({
        username: '@gina', rememberToken: 'rt-dead', deviceId: 'dev-1',
      });
      return resumed.ok ? resumed.token : null;
    };

    const result = await api.getUsers();

    expect(result.ok).toBe(false);
    expect(result.status).toBe(401);
    // One fetch for the original /api/users call, one for the /api/session/resume
    // attempt it triggers — resume's own 401 must not re-enter the hook.
    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(onAuthExpiredCalls).toBe(1);

    vi.unstubAllGlobals();
  });

  it('retries the original request once onAuthExpired resolves with a fresh token, and returns its result', async () => {
    // Coverage gap flagged alongside the test above: that one proves a DEAD
    // token does not loop forever, but nothing proved the ordinary happy
    // path -- a 401, a successful silent resume, and the original request
    // going through a second time with the new token attached.
    let callCount = 0;
    const fetchMock = vi.fn(async (url, options) => {
      callCount += 1;
      if (callCount === 1) {
        expect(options.headers.Authorization).toBe('Bearer tok-old');
        return { ok: false, status: 401, json: async () => ({ error: 'Unauthorized' }) };
      }
      expect(options.headers.Authorization).toBe('Bearer tok-fresh');
      return { ok: true, status: 200, json: async () => ({ users: [] }) };
    });
    vi.stubGlobal('fetch', fetchMock);

    const api = new RcApi('localhost:8666');
    api.token = 'tok-old';
    let onAuthExpiredCalls = 0;
    api.onAuthExpired = async () => {
      onAuthExpiredCalls += 1;
      return 'tok-fresh';
    };

    const result = await api.getUsers();

    expect(result).toEqual({ ok: true, users: [] });
    expect(api.token).toBe('tok-fresh');
    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(onAuthExpiredCalls).toBe(1);

    vi.unstubAllGlobals();
  });
});

describe('version compatibility', () => {
  // The rule and its rationale live in client.js's CLIENT_VERSION block; the
  // twins are crates/cambium-core/src/version.rs and CambiumServer/server.py.
  // All three must agree, so these mirror the Rust tests case for case.
  it('reads the major component', () => {
    expect(majorOf('0.2.0')).toBe(0);
    expect(majorOf('1.0.0')).toBe(1);
    expect(majorOf('12.3.4')).toBe(12);
    expect(majorOf(' 2.0.0 ')).toBe(2);
  });

  it('treats an unreadable version as null, not zero', () => {
    // Collapsing to 0 would make garbage read as compatible with a 0.x build.
    for (const bad of ['', 'nightly', 'v1.0.0', null, undefined, '1abc.0.0']) {
      expect(majorOf(bad)).toBeNull();
    }
  });

  it('allows minor and patch skew inside one major line', () => {
    expect(isCompatible('0.2.0', '0.2.0')).toBe(true);
    expect(isCompatible('0.2.0', '0.9.9')).toBe(true);
    expect(isCompatible('0.2.0', '0.1.0')).toBe(true);
    expect(isCompatible('1.4.0', '1.0.0')).toBe(true);
  });

  it('refuses a major mismatch in both directions', () => {
    expect(isCompatible('0.2.0', '1.0.0')).toBe(false);
    expect(isCompatible('1.0.0', '0.2.0')).toBe(false);
    expect(isCompatible('1.0.0', '2.0.0')).toBe(false);
  });

  it('refuses an unreadable version rather than assuming it is current', () => {
    expect(isCompatible('0.2.0', '')).toBe(false);
    expect(isCompatible('0.2.0', 'unknown')).toBe(false);
    expect(isCompatible('', '0.2.0')).toBe(false);
  });

  it('ships a version this module can read', () => {
    // A release that bumps package.json to something unparseable would
    // refuse every server; fail here instead of in the field.
    expect(majorOf(CLIENT_VERSION)).not.toBeNull();
  });

  it('names the side that is behind', () => {
    const newer = incompatibilityMessage('99.0.0');
    expect(newer).toMatch(/newer/);
    expect(newer).toMatch(/Update the client/);
    expect(incompatibilityMessage('banana')).toMatch(/unrecognized/);
  });
});

describe('RcApi request headers', () => {
  it('stamps the version on avatar downloads too, which bypass RcApi.request', async () => {
    // Regression guard: avatarCache calls platform.apiFetch directly rather
    // than going through RcApi, so a header attached in RcApi.request alone
    // would leave avatar downloads to be refused with 426. The header is
    // attached in apiFetch for exactly this reason.
    const fetchMock = vi.fn().mockResolvedValue({
      ok: true, status: 200, text: async () => 'AAAA',
    });
    vi.stubGlobal('fetch', fetchMock);
    const { resolveAvatarUrl } = await import('../data/avatarCache');
    await resolveAvatarUrl('http://localhost:8666/api/avatars/a.png?v=1');
    expect(fetchMock).toHaveBeenCalled();
    const [, options] = fetchMock.mock.calls[0];
    expect(options.headers[CLIENT_VERSION_HEADER]).toBe(CLIENT_VERSION);
    vi.unstubAllGlobals();
  });

  it('stamps the client version on every request, not just the handshake', async () => {
    // The server gates each request on this header (426 on a mismatch), and
    // the streaming transfer routes never touch login — so a request that
    // omits it is one the server refuses.
    const fetchMock = vi.fn().mockResolvedValue({
      ok: true, status: 200, json: async () => ({}),
    });
    vi.stubGlobal('fetch', fetchMock);
    const api = new RcApi('localhost:8666');
    await api.getServer();
    await api.listProjects();
    for (const [, options] of fetchMock.mock.calls) {
      expect(options.headers[CLIENT_VERSION_HEADER]).toBe(CLIENT_VERSION);
    }
    vi.unstubAllGlobals();
  });

  it('no longer sends the dead clientVersion body field', async () => {
    // It was hardcoded to '0.1.0' and the server never read it; the header
    // is the real mechanism.
    const fetchMock = vi.fn().mockResolvedValue({
      ok: true, status: 200, json: async () => ({ token: 't' }),
    });
    vi.stubGlobal('fetch', fetchMock);
    const api = new RcApi('localhost:8666');
    await api.login({
      username: '@gina', password: 'pw', deviceId: 'dev-1', deviceName: 'box',
    });
    const [, options] = fetchMock.mock.calls[0];
    expect(JSON.parse(options.body)).not.toHaveProperty('clientVersion');
    vi.unstubAllGlobals();
  });
});
