// @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 { 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,
  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() {
      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.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('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/);
  });
});
