// @vitest-environment happy-dom
//
// Poll dedupe: a 20s poll whose payloads are byte-identical to the cached
// bucket must NOT produce new object identities (downstream effects key on
// identity). Optimistic writers must
// invalidate the signature so server truth still lands afterwards.
// Convention per ConnectionContext.test.jsx: no react-testing-library;
// mount with react-dom/client and read state off a probe component.
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const conn = vi.hoisted(() => ({ current: null }));

vi.mock('./ConnectionContext', () => ({
  useConnection: () => conn.current,
}));

import { ServerDataProvider, useServerData } from './ServerDataContext';

// Mutable backing data; every api call returns a DEEP CLONE so each poll
// delivers fresh identities exactly like real JSON parsing does.
const data = {
  users: [{ username: '@alice', displayName: 'Alice', removed: false }],
  revisions: [],
  depot: {
    children: [{ type: 'file', name: 'a.txt', hash: 'h1', sizeBytes: 1, headRevision: 1 }],
    modifiedMs: 5,
  },
  locks: {},
  availability: {},
  meetings: [],
};

function makeApi() {
  return {
    getUsers: vi.fn(async () => ({ ok: true, users: structuredClone(data.users) })),
    getRevisions: vi.fn(async () => ({ ok: true, revisions: structuredClone(data.revisions) })),
    getDepotTree: vi.fn(async () => ({
      ok: true,
      children: structuredClone(data.depot.children),
      modifiedMs: data.depot.modifiedMs,
    })),
    getLocks: vi.fn(async () => ({ ok: true, locks: structuredClone(data.locks) })),
    getAvailability: vi.fn(async () => ({
      ok: true,
      availability: structuredClone(data.availability),
      meetings: structuredClone(data.meetings),
    })),
    getIgnore: vi.fn(async () => ({ ok: true, text: '' })),
    avatarUrl: () => null,
  };
}

const seen = [];
let ctxRef = null;
function Probe() {
  const ctx = useServerData();
  ctxRef = ctx;
  seen.push({ depotTree: ctx.depotTree, locks: ctx.locks, users: ctx.users });
  return null;
}

let container;
let root;

async function mount() {
  await act(async () => {
    root.render(
      <ServerDataProvider>
        <Probe />
      </ServerDataProvider>
    );
  });
  // Flush the six initial fetches.
  await act(async () => {
    await vi.advanceTimersByTimeAsync(0);
  });
}

async function poll() {
  await act(async () => {
    await vi.advanceTimersByTimeAsync(20000);
  });
}

beforeEach(() => {
  vi.useFakeTimers();
  seen.length = 0;
  ctxRef = null;
  data.depot.children[0].hash = 'h1';
  data.depot.modifiedMs = 5;
  conn.current = {
    loggedIn: true,
    projectReady: true,
    api: makeApi(),
    account: null,
    activeProjectId: 'p1',
  };
  container = document.createElement('div');
  document.body.appendChild(container);
  root = createRoot(container);
});

afterEach(async () => {
  await act(async () => root.unmount());
  container.remove();
  vi.useRealTimers();
});

describe('ServerDataContext poll dedupe', () => {
  it('keeps bucket identities stable across a no-change poll', async () => {
    await mount();
    const before = seen.at(-1);
    expect(before.depotTree).not.toBeNull();
    await poll();
    const after = seen.at(-1);
    expect(after.depotTree).toBe(before.depotTree);
    expect(after.locks).toBe(before.locks);
  });

  it('applies a poll whose payload actually changed', async () => {
    await mount();
    const before = seen.at(-1).depotTree;
    data.depot.children[0].hash = 'h2';
    data.depot.modifiedMs = 6;
    await poll();
    const after = seen.at(-1).depotTree;
    expect(after).not.toBe(before);
    expect(after.children[0].hash).toBe('h2');
  });

  it('optimistic setLocks does not mask the next poll (sig invalidation)', async () => {
    await mount();
    // Optimistic checkout: bucket now shows a lock the server never had.
    act(() => {
      ctxRef.setLocks({ 'a.txt': { username: '@alice', deviceId: 'd', deviceName: 'x' } });
    });
    expect(seen.at(-1).locks['a.txt']).toBeTruthy();
    // Server truth is still {} — the poll MUST overwrite the optimistic
    // value even though {} byte-matches the pre-optimistic payload.
    await poll();
    expect(seen.at(-1).locks).toEqual({});
  });
});

describe('a session with no active project', () => {
  // A server whose first project doesn't exist yet still has a user roster,
  // and User Management (under Administration) is how you manage it. Only
  // /api/users is org-scoped — it applies its project filter solely when an
  // X-Project header is present — so it alone keeps polling here.
  beforeEach(() => {
    conn.current = { ...conn.current, projectReady: false, activeProjectId: null };
  });

  it('still loads the org roster', async () => {
    await mount();
    expect(ctxRef.users).toEqual(data.users);
    expect(ctxRef.usersLoaded).toBe(true);
  });

  it('leaves the five project-scoped fetches alone', async () => {
    await mount();
    const api = conn.current.api;
    expect(api.getUsers).toHaveBeenCalled();
    expect(api.getRevisions).not.toHaveBeenCalled();
    expect(api.getDepotTree).not.toHaveBeenCalled();
    expect(api.getLocks).not.toHaveBeenCalled();
    expect(api.getAvailability).not.toHaveBeenCalled();
    expect(api.getIgnore).not.toHaveBeenCalled();
  });

  it('keeps polling the roster, and only the roster', async () => {
    await mount();
    const api = conn.current.api;
    const before = api.getUsers.mock.calls.length;
    await poll();
    expect(api.getUsers.mock.calls.length).toBeGreaterThan(before);
    expect(api.getDepotTree).not.toHaveBeenCalled();
  });

  it('fetches nothing at all once logged out', async () => {
    conn.current = { ...conn.current, loggedIn: false };
    await mount();
    expect(conn.current.api.getUsers).not.toHaveBeenCalled();
    expect(ctxRef.users).toEqual([]);
  });
});
