import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  setFallbackSession,
  removeFallbackSession,
  getFallbackSession,
  subscribeSessionChanges,
} from './sessions';

// The single-key atomic blob (kept in sync with SESSION_BLOB_KEY in sessions.ts).
const SESSION_BLOB_KEY = 'cinny_session_v1';

// The fallback-session helpers read/write specific `cinny_*` keys directly on
// `localStorage`. node has none, so install a controllable in-memory mock per
// case backed by a Map.
const installStorage = (): Map<string, string> => {
  const store = new Map<string, string>();
  (globalThis as { localStorage?: unknown }).localStorage = {
    getItem: (key: string) => (store.has(key) ? store.get(key) : null),
    setItem: (key: string, value: string) => {
      store.set(key, String(value));
    },
    removeItem: (key: string) => {
      store.delete(key);
    },
  };
  return store;
};

test('setFallbackSession writes the cinny_* keys', () => {
  const store = installStorage();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');

  assert.equal(store.get('cinny_access_token'), 'token-1');
  assert.equal(store.get('cinny_device_id'), 'DEVICE1');
  assert.equal(store.get('cinny_user_id'), '@alice:example.org');
  assert.equal(store.get('cinny_hs_base_url'), 'https://hs.example.org');
});

test('getFallbackSession round-trips a full session and flags fallback stores', () => {
  installStorage();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');

  assert.deepEqual(getFallbackSession(), {
    baseUrl: 'https://hs.example.org',
    userId: '@alice:example.org',
    deviceId: 'DEVICE1',
    accessToken: 'token-1',
    fallbackSdkStores: true,
  });
});

test('getFallbackSession returns undefined when nothing is stored', () => {
  installStorage();
  assert.equal(getFallbackSession(), undefined);
});

test('legacy path: undefined when a single legacy key is missing (no blob)', () => {
  // With no atomic blob, every one of the four legacy keys is required; missing
  // any one yields undefined (the pre-blob behaviour).
  const keys = [
    'cinny_access_token',
    'cinny_device_id',
    'cinny_user_id',
    'cinny_hs_base_url',
  ] as const;

  keys.forEach((missing) => {
    installStorage();
    setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
    localStorage.removeItem(SESSION_BLOB_KEY);
    localStorage.removeItem(missing);
    assert.equal(getFallbackSession(), undefined, `missing ${missing} should yield undefined`);
  });
});

test('blob wins: a torn legacy key does NOT tear the session while the blob exists', () => {
  installStorage();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
  // Simulate a torn legacy write — the authoritative blob must still resolve.
  localStorage.removeItem('cinny_access_token');
  assert.deepEqual(getFallbackSession(), {
    baseUrl: 'https://hs.example.org',
    userId: '@alice:example.org',
    deviceId: 'DEVICE1',
    accessToken: 'token-1',
    fallbackSdkStores: true,
  });
});

test('removeFallbackSession clears all keys', () => {
  const store = installStorage();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
  removeFallbackSession();

  assert.equal(store.size, 0);
  assert.equal(getFallbackSession(), undefined);
});

test('round-trips an OIDC session (refresh token, expiry, oidc metadata)', () => {
  installStorage();
  setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://matrix-client.mozilla.org', {
    refreshToken: 'refresh-xyz',
    expiresInMs: 3_600_000,
    oidc: {
      issuer: 'https://chat.mozilla.org/',
      clientId: 'client-123',
      redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback',
      idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' },
    },
  });

  const s = getFallbackSession();
  assert.ok(s);
  assert.equal(s.refreshToken, 'refresh-xyz');
  // stored as absolute expiry, read back as remaining lifetime
  assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000);
  assert.deepEqual(s.oidc, {
    issuer: 'https://chat.mozilla.org/',
    clientId: 'client-123',
    redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback',
    idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' },
  });
});

test('a password session carries no OIDC fields, and re-saving clears stale OIDC keys', () => {
  installStorage();
  // first an OIDC session...
  setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
    refreshToken: 'r',
    oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
  });
  assert.ok(getFallbackSession()?.oidc);
  // ...overwritten by a plain password session must drop the OIDC state
  setFallbackSession('tok2', 'DEV', '@alice:example.org', 'https://hs');
  const s = getFallbackSession();
  assert.ok(s);
  assert.equal(s.oidc, undefined);
  assert.equal(s.refreshToken, undefined);
  // The overwritten blob must not retain the stale OIDC state either.
  const blob = JSON.parse(localStorage.getItem(SESSION_BLOB_KEY)!);
  assert.equal(blob.oidc, undefined);
  assert.equal(blob.refreshToken, undefined);
});

// ---------------------------------------------------------------------------
// Atomic blob: write/read round-trip
// ---------------------------------------------------------------------------

test('setFallbackSession writes a single atomic blob under cinny_session_v1', () => {
  const store = installStorage();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');

  const raw = store.get(SESSION_BLOB_KEY);
  assert.ok(raw, 'blob key must be written');
  assert.deepEqual(JSON.parse(raw!), {
    accessToken: 'token-1',
    deviceId: 'DEVICE1',
    userId: '@alice:example.org',
    baseUrl: 'https://hs.example.org',
  });
});

test('blob round-trips a full OIDC session (absolute expiry stored, remaining read back)', () => {
  const store = installStorage();
  setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
    refreshToken: 'refresh-xyz',
    expiresInMs: 3_600_000,
    oidc: {
      issuer: 'https://i',
      clientId: 'c',
      redirectUri: 'https://cb',
      idTokenClaims: { sub: '@bob:mozilla.org' },
    },
  });

  const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
  assert.equal(blob.refreshToken, 'refresh-xyz');
  assert.ok(typeof blob.expiresAt === 'number' && blob.expiresAt > Date.now());
  assert.deepEqual(blob.oidc, {
    issuer: 'https://i',
    clientId: 'c',
    redirectUri: 'https://cb',
    idTokenClaims: { sub: '@bob:mozilla.org' },
  });

  const s = getFallbackSession();
  assert.ok(s);
  assert.equal(s.refreshToken, 'refresh-xyz');
  assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000);
  assert.deepEqual(s.oidc, {
    issuer: 'https://i',
    clientId: 'c',
    redirectUri: 'https://cb',
    idTokenClaims: { sub: '@bob:mozilla.org' },
  });
});

// ---------------------------------------------------------------------------
// Migration: legacy-only storage → transparent read → blob persisted on write
// ---------------------------------------------------------------------------

test('legacy-only storage (no blob) is read transparently', () => {
  const store = installStorage();
  // Simulate an older build: legacy keys present, no blob.
  store.set('cinny_access_token', 'tok');
  store.set('cinny_device_id', 'DEV');
  store.set('cinny_user_id', '@carol:example.org');
  store.set('cinny_hs_base_url', 'https://hs');
  assert.equal(store.has(SESSION_BLOB_KEY), false);

  assert.deepEqual(getFallbackSession(), {
    baseUrl: 'https://hs',
    userId: '@carol:example.org',
    deviceId: 'DEV',
    accessToken: 'tok',
    fallbackSdkStores: true,
  });
});

test('first write after a legacy-only read persists the blob (migration)', () => {
  const store = installStorage();
  store.set('cinny_access_token', 'old');
  store.set('cinny_device_id', 'DEV');
  store.set('cinny_user_id', '@carol:example.org');
  store.set('cinny_hs_base_url', 'https://hs');

  // Reads are side-effect free — no blob yet.
  getFallbackSession();
  assert.equal(store.has(SESSION_BLOB_KEY), false);

  // The next write (e.g. a token refresh) persists the atomic blob.
  setFallbackSession('new', 'DEV', '@carol:example.org', 'https://hs');
  assert.ok(store.has(SESSION_BLOB_KEY));
  assert.equal(getFallbackSession()?.accessToken, 'new');
});

// ---------------------------------------------------------------------------
// Corruption / partial blob → legacy fallback; blob wins on disagreement
// ---------------------------------------------------------------------------

test('corrupt blob (bad JSON) falls back to the legacy keys', () => {
  const store = installStorage();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
  // Corrupt the blob but keep the legacy keys intact.
  store.set(SESSION_BLOB_KEY, '{not valid json');

  assert.deepEqual(getFallbackSession(), {
    baseUrl: 'https://hs.example.org',
    userId: '@alice:example.org',
    deviceId: 'DEVICE1',
    accessToken: 'token-1',
    fallbackSdkStores: true,
  });
});

test('partial blob (missing a required field) falls back to the legacy keys', () => {
  const store = installStorage();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
  // A blob missing accessToken is treated as absent.
  store.set(
    SESSION_BLOB_KEY,
    JSON.stringify({ deviceId: 'DEVICE1', userId: '@alice:example.org', baseUrl: 'https://hs' }),
  );

  assert.equal(getFallbackSession()?.accessToken, 'token-1');
});

test('blob wins when blob and legacy keys disagree', () => {
  const store = installStorage();
  setFallbackSession('blob-token', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
  // Legacy keys drift to a stale token; the blob is authoritative.
  store.set('cinny_access_token', 'stale-legacy-token');

  assert.equal(getFallbackSession()?.accessToken, 'blob-token');
});

// ---------------------------------------------------------------------------
// Dual-write keeps blob + legacy in sync; removal clears both
// ---------------------------------------------------------------------------

test('dual-write keeps the legacy keys in sync with the blob', () => {
  const store = installStorage();
  setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
    refreshToken: 'r',
    expiresInMs: 1000,
    oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
  });

  // Legacy credential keys
  assert.equal(store.get('cinny_access_token'), 'tok');
  assert.equal(store.get('cinny_device_id'), 'DEV');
  assert.equal(store.get('cinny_user_id'), '@bob:mozilla.org');
  assert.equal(store.get('cinny_hs_base_url'), 'https://hs');
  // Legacy OIDC keys
  assert.equal(store.get('cinny_refresh_token'), 'r');
  assert.ok(store.has('cinny_expires_at'));
  assert.equal(store.get('cinny_oidc_issuer'), 'https://i');
  assert.equal(store.get('cinny_oidc_client_id'), 'c');
  assert.equal(store.get('cinny_oidc_redirect_uri'), 'https://cb');
  // Blob agrees
  const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
  assert.equal(blob.accessToken, 'tok');
  assert.equal(blob.refreshToken, 'r');
  assert.equal(store.get('cinny_expires_at'), String(blob.expiresAt));
});

test('removeFallbackSession clears BOTH the blob and every legacy key', () => {
  const store = installStorage();
  setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', {
    refreshToken: 'r',
    expiresInMs: 1000,
    oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
  });
  assert.ok(store.size > 0);

  removeFallbackSession();
  assert.equal(store.size, 0, 'no session key may survive removal');
  assert.equal(getFallbackSession(), undefined);
});

// ---------------------------------------------------------------------------
// Token-refresh update path (the path LotusOidcTokenRefresher uses)
// ---------------------------------------------------------------------------

test('token refresh via setFallbackSession updates blob + legacy atomically', () => {
  const store = installStorage();
  // Initial OIDC session.
  setFallbackSession('access-1', 'DEV', '@bob:mozilla.org', 'https://hs', {
    refreshToken: 'refresh-1',
    oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
  });

  // LotusOidcTokenRefresher.persistTokens() calls setFallbackSession with the
  // rotated tokens and the same identity/oidc refs.
  setFallbackSession('access-2', 'DEV', '@bob:mozilla.org', 'https://hs', {
    refreshToken: 'refresh-2',
    oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' },
  });

  // Blob updated
  const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!);
  assert.equal(blob.accessToken, 'access-2');
  assert.equal(blob.refreshToken, 'refresh-2');
  // Legacy keys updated in lockstep
  assert.equal(store.get('cinny_access_token'), 'access-2');
  assert.equal(store.get('cinny_refresh_token'), 'refresh-2');
  // Reader sees the fresh token
  const s = getFallbackSession();
  assert.equal(s?.accessToken, 'access-2');
  assert.equal(s?.refreshToken, 'refresh-2');
});

// ---------------------------------------------------------------------------
// Cross-tab sync: subscribeSessionChanges
// ---------------------------------------------------------------------------

// Minimal window/storage-event harness: node has neither.
const installWindow = (): ((evt: { key: string | null }) => void)[] => {
  const listeners: ((evt: { key: string | null }) => void)[] = [];
  (globalThis as { window?: unknown }).window = {
    addEventListener: (type: string, cb: (evt: { key: string | null }) => void) => {
      if (type === 'storage') listeners.push(cb);
    },
    removeEventListener: (type: string, cb: (evt: { key: string | null }) => void) => {
      if (type !== 'storage') return;
      const i = listeners.indexOf(cb);
      if (i !== -1) listeners.splice(i, 1);
    },
  };
  return listeners;
};

test('subscribeSessionChanges fires with the session when a session key changes', () => {
  installStorage();
  const listeners = installWindow();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');

  let received: unknown = 'unset';
  const unsub = subscribeSessionChanges((s) => {
    received = s;
  });

  // Simulate another tab writing a new token, then dispatch the storage event.
  setFallbackSession('token-2', 'DEVICE1', '@alice:example.org', 'https://hs');
  listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY }));

  assert.notEqual(received, 'unset');
  assert.equal((received as { accessToken?: string })?.accessToken, 'token-2');

  unsub();
  assert.equal(listeners.length, 0, 'unsubscribe removes the listener');
});

test('subscribeSessionChanges fires with null when the session is removed', () => {
  installStorage();
  const listeners = installWindow();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');
  subscribeSessionChanges((s) => {
    assert.equal(s, null);
  });

  removeFallbackSession();
  listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY }));
});

test('subscribeSessionChanges treats a null key (localStorage.clear) as a change', () => {
  const store = installStorage();
  const listeners = installWindow();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');

  let fired = false;
  subscribeSessionChanges(() => {
    fired = true;
  });

  store.clear();
  listeners.forEach((cb) => cb({ key: null }));
  assert.equal(fired, true);
});

test('subscribeSessionChanges ignores unrelated storage keys', () => {
  installStorage();
  const listeners = installWindow();
  setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs');

  let fired = false;
  subscribeSessionChanges(() => {
    fired = true;
  });

  listeners.forEach((cb) => cb({ key: 'some_unrelated_preference' }));
  assert.equal(fired, false);
});
