import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { RcApi, CLIENT_VERSION, isCompatible, incompatibilityMessage } from '../api/client';
import {
  getDeviceName,
  fetchServerCertificate,
  isTauri,
  saveSecret,
  loadSecret,
  deleteSecret,
  deviceId,
  loadWorkspacesNative,
  saveWorkspacesNative,
} from '../platform';
import { getPin, setPin, removePin, hostFromIp } from '../data/serverPins';
import { credentialAccountKey } from '../data/credentialAccount';
import { hydrate, writeThrough } from '../data/nativeMirror';

const ORG_CONNECTION_KEY = 'cbm-org-connection-v1';
const WORKSPACES_KEY = 'cbm-workspaces-v1';
const LAST_PROJECT_KEY = 'cbm-last-project-v1';
// How long the boot-time silent resume gets before the Login page is shown
// anyway. Generous enough that a slow-but-alive server still wins the race.
const STARTUP_RESUME_TIMEOUT_MS = 10_000;

/**
 * Client-side org/session state, backed by a live Revision Control server
 * (CambiumServer/). A server is an *Organization* hosting many *Projects*.
 *
 * Persisted locally: a single org connection (prefills the Login page) and a
 * per-project workspace-folder map. Everything about the account (display name,
 * avatar, timezone, status, devices) lives on the server and is fetched at
 * login. The org session — and the project list it exposes — is in-memory: the
 * app opens disconnected, and either resumes the remembered connection from its
 * stored device-bound token (see the startup effect below) or presents Login.
 */

function loadJson(key, fallback) {
  try {
    const saved = localStorage.getItem(key);
    if (saved) return JSON.parse(saved);
  } catch {
    /* use fallback */
  }
  return fallback;
}

function saveJson(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
  } catch {
    /* ignore (quota) */
  }
}

/** Usernames are handles: always exactly one leading "@". Empty stays empty. */
export function normalizeHandle(value) {
  const stripped = value.replace(/@/g, '');
  return stripped ? `@${stripped}` : '';
}

// Native <-> legacy-localStorage shape conversion for the workspaces map (see
// data/nativeMirror.js for the hydrate/writeThrough pattern). Native shape:
// { formatVersion, activeProject, workspaces: { id: { name, folder } } } —
// the CLI links projects by name, so the name has to travel through here too.
// Legacy shape (what the rest of this file's state/localStorage uses):
// { [projectId]: folder }.

/** Native workspaces_load() JSON -> legacy `{ projectId: folder }` JSON, or
 * null if native has nothing yet (signals hydrate() to consider migrating
 * localStorage's copy up), or undefined if the read/parse failed. */
function legacyWorkspacesJsonFromNative(raw) {
  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return undefined;
  }
  const entries = Object.entries(parsed?.workspaces ?? {});
  if (!entries.length) return null;
  const legacy = {};
  for (const [id, entry] of entries) legacy[id] = entry?.folder ?? '';
  return JSON.stringify(legacy);
}

async function nativeWorkspacesLoader() {
  const raw = await loadWorkspacesNative();
  if (raw == null) return undefined; // web, or the native read failed
  return legacyWorkspacesJsonFromNative(raw);
}

/** One-time migration path (native empty, localStorage has an existing map):
 * project names aren't known yet this early (they arrive from the server
 * after login), so each entry seeds with an empty name — the very next
 * write-through (see the effect below, once `projects` has loaded) corrects
 * it. */
async function nativeWorkspacesPusher(legacyJson) {
  let legacy;
  try {
    legacy = JSON.parse(legacyJson) ?? {};
  } catch {
    legacy = {};
  }
  const workspaces = {};
  for (const [id, folder] of Object.entries(legacy)) workspaces[id] = { name: '', folder };
  await saveWorkspacesNative(
    JSON.stringify({ formatVersion: 1, activeProject: null, workspaces })
  );
}

/** Legacy `{ projectId: folder }` state -> native workspaces_save() JSON,
 * filling in each project's current display name from the live projects
 * list so the CLI can `link` by name. */
function nativeWorkspacesJson(workspaces, projects, activeProjectId) {
  const nameById = new Map(projects.map((p) => [p.id, p.name]));
  const entries = {};
  for (const [id, folder] of Object.entries(workspaces)) {
    entries[id] = { name: nameById.get(id) ?? '', folder };
  }
  return JSON.stringify({
    formatVersion: 1,
    activeProject: activeProjectId ?? null,
    workspaces: entries,
  });
}

const ConnectionContext = createContext(null);

export function ConnectionProvider({ children }) {
  // The one remembered org connection { ip, username, remember }; prefills
  // the Login page. Logging in with a different IP replaces it.
  const [orgConnection, setOrgConnection] = useState(() => {
    const saved = loadJson(ORG_CONNECTION_KEY, null);
    if (!saved) return saved;
    const { ip, username, remember, rememberExpiresAt } = saved;
    return { ip, username, remember, rememberExpiresAt };
  });
  // Boot-time silent resume (see the effect below). `startupPending` is true
  // from the very first render whenever there is a remembered connection to
  // try, so the UI can hold off presenting Login until the attempt settles
  // rather than flashing a form the user was never meant to see.
  // `startupError` is why that attempt failed, handed to the Login page when
  // it does open — a fallback must say what went wrong, not just appear.
  const [startupPending, setStartupPending] = useState(() => Boolean(orgConnection?.remember));
  const [startupError, setStartupError] = useState(null);
  // Whether the resume has already been dispatched. See the effect for why
  // this survives more than a local variable needs to.
  const startupAttempted = useRef(false);
  // { api, user, org } after a successful login — not persisted.
  const [session, setSession] = useState(null);
  // Set when login is blocked because this account is at its device limit:
  // { api, devices, maxDevices, creds }. The device-limit modal drives it —
  // the user removes a device, then finishes to register this one and log in.
  const [deviceLimit, setDeviceLimit] = useState(null);
  // Trust-on-first-use flow (desktop only — see fetchServerCertificate).
  // trustPrompt: { cert, creds } while waiting on first-connect accept/decline.
  // pinMismatch: { host } — a HARD STOP; the only way out is forgetting the
  // saved identity (no inline "continue anyway").
  const [trustPrompt, setTrustPrompt] = useState(null);
  const [pinMismatch, setPinMismatch] = useState(null);
  // Projects the caller belongs to, and which one is active. Exactly one is
  // active whenever the list is non-empty.
  const [projects, setProjects] = useState([]);
  const [activeProjectId, setActiveProjectId] = useState(null);
  // Per-project workspace folders, keyed by project id. Editing a card's
  // folder behaves exactly as the old connection card did.
  const [workspaces, setWorkspaces] = useState(() => loadJson(WORKSPACES_KEY, {}));

  const [hostName, setHostName] = useState('this-device');
  // Bumped on every successful login so consumers (e.g. the file-tree tab
  // context) can reset per-login UI state even though this provider — and
  // everything below it — stays mounted for the app's whole lifetime.
  const [loginSeq, setLoginSeq] = useState(0);
  // True once the boot-time native->localStorage hydration below has run at
  // least once. Gates the write-through effect: without it, the FIRST render
  // (workspaces state lazily read from localStorage, before hydration has had
  // a chance to run) would immediately push that possibly-stale/empty map to
  // native and clobber a richer shared workspaces.json the moment this app
  // version first launches.
  const [workspacesHydrated, setWorkspacesHydrated] = useState(false);

  // Boot-time native mirror hydration (see data/nativeMirror.js): seed
  // localStorage's workspaces map from the shared workspaces.json, migrating
  // any pre-existing localStorage-only map up to native the first time. Runs
  // once; the write-through effect below waits for it.
  useEffect(() => {
    let cancelled = false;
    hydrate(WORKSPACES_KEY, nativeWorkspacesLoader, nativeWorkspacesPusher).then(() => {
      if (cancelled) return;
      setWorkspaces(loadJson(WORKSPACES_KEY, {}));
      setWorkspacesHydrated(true);
    });
    return () => {
      cancelled = true;
    };
  }, []);

  // Write-through on every persistence: localStorage immediately (sync
  // readers), native in the background. Re-fires when `projects` or
  // `activeProjectId` change too (not just `workspaces`) so a workspace map
  // written before login — with no project names yet available — gets
  // corrected with real names as soon as the project list loads.
  useEffect(() => {
    if (!workspacesHydrated) return;
    writeThrough(WORKSPACES_KEY, JSON.stringify(workspaces), () =>
      saveWorkspacesNative(nativeWorkspacesJson(workspaces, projects, activeProjectId))
    );
  }, [workspaces, projects, activeProjectId, workspacesHydrated]);

  useEffect(() => {
    let cancelled = false;
    getDeviceName().then((name) => {
      if (!cancelled && name) setHostName(name);
    });
    return () => {
      cancelled = true;
    };
  }, []);

  const loggedIn = Boolean(session);
  const activeProject = useMemo(
    () => projects.find((p) => p.id === activeProjectId) ?? null,
    [projects, activeProjectId]
  );
  // Ready to do *project* work: logged in AND a project is active.
  // Logged-in-with-no-projects is the Projects page's empty state.
  //
  // Deliberately not called "connected": it was, and every server-scoped
  // consumer read it as "connected to a server", which hid Administration and
  // Organization on a server with no projects. Since creating the first
  // project lives under Administration, that made a fresh server impossible to
  // set up through the GUI. Use `loggedIn` for anything server-scoped.
  const projectReady = Boolean(session && activeProject);

  // Point the API client at a project and remember it as this org's last-used.
  const selectProject = useCallback(
    (projectId) => {
      setActiveProjectId(projectId);
      session?.api.setActiveProject(projectId);
      if (session && projectId) {
        const last = loadJson(LAST_PROJECT_KEY, {});
        saveJson(LAST_PROJECT_KEY, { ...last, [session.api.baseUrl]: projectId });
      }
    },
    [session]
  );

  // Load (or reload) the project list for the current session, keeping the
  // active selection valid: prefer the still-present current selection, then
  // the org's last-used project, then the first card.
  const refreshProjects = useCallback(async () => {
    if (!session) return { ok: false, error: 'Not logged in.' };
    const result = await session.api.listProjects();
    if (!result.ok) {
      // Token expired and the silent stored-credential re-login (onAuthExpired,
      // wired in login() above) couldn't recover it either — drop to the login
      // screen rather than leaving the app stuck on a dead session.
      if (result.status === 401) setSession(null);
      return result;
    }
    const list = result.projects ?? [];
    setProjects(list);
    setActiveProjectId((current) => {
      const stillValid = list.some((p) => p.id === current);
      if (stillValid) return current;
      const lastUsed = loadJson(LAST_PROJECT_KEY, {})[session.api.baseUrl];
      const next = list.find((p) => p.id === lastUsed)?.id ?? list[0]?.id ?? null;
      session.api.setActiveProject(next);
      return next;
    });
    return { ok: true, projects: list };
  }, [session]);

  const login = useCallback(
    async ({ ip, username, password, remember = true }) => {
      // Any deliberate attempt supersedes whatever the boot-time resume had
      // to say, so the Login page never re-opens later still showing it.
      setStartupError(null);
      const trimmedIp = ip.trim();
      const trimmedUser = username.trim();
      if (!trimmedIp || !trimmedUser) {
        return { ok: false, error: 'Enter an IP and username first.' };
      }
      if (normalizeHandle(trimmedUser).length < 2) {
        return { ok: false, error: 'Usernames are @handles — enter one after the @.' };
      }

      // Trust-on-first-use: an https server with no pinned fingerprint yet
      // gets a fingerprint prompt (the pinned fetch in Rust refuses to send
      // credentials to an unpinned host) before any login attempt goes out.
      // Plain http:// (dev) skips this entirely. There's no pinning story on
      // web (see fetchServerCertificate/apiFetch in platform.js), so this is
      // desktop-only.
      const usesTls = !/^http:\/\//.test(trimmedIp);
      const host = hostFromIp(trimmedIp);
      if (isTauri && usesTls && !getPin(host)) {
        try {
          const cert = await fetchServerCertificate(host);
          setTrustPrompt({ cert, creds: { ip: trimmedIp, username: trimmedUser, password, remember } });
          return { ok: false, trustRequired: true };
        } catch {
          return { ok: false, error: 'Could not reach the server. Is it running?' };
        }
      }

      const api = new RcApi(trimmedIp);

      // Refuse an incompatible server before any credential leaves this
      // machine. /api/server is unauthenticated and deliberately exempt from
      // the server's own version gate, so it stays readable across a major
      // mismatch — which is what makes this check possible at all, and what
      // lets the error say which side needs upgrading instead of just
      // failing. The server enforces the same rule on its side (426).
      const serverInfo = await api.getServer();
      if (serverInfo.pinMismatch) {
        setPinMismatch({ host });
        return { ok: false, error: serverInfo.error };
      }
      if (!serverInfo.ok) {
        // Only a genuine transport failure deserves "is it running?". A
        // *reply* — any HTTP status — means it is running and something else
        // is wrong, and saying otherwise sends people to check their server
        // process instead of the real fault. This is what hid the keep-alive
        // desync behind "the first login after a logout always fails": the
        // server was answering 501, and this line called it unreachable.
        if (serverInfo.status) {
          return { ok: false, error: `${serverInfo.error} (HTTP ${serverInfo.status})` };
        }
        return { ok: false, error: 'Could not reach the server. Is it running?' };
      }
      if (!isCompatible(CLIENT_VERSION, serverInfo.version)) {
        return { ok: false, error: incompatibilityMessage(serverInfo.version) };
      }

      // Shared tail of every successful login, however the session was
      // obtained (password or resume token): credential bookkeeping, wiring
      // the silent-resume hook, and loading/auto-selecting a project.
      // `connection` is what the caller knew before the round trip
      // (ip/username/remember) — a resumed session has no `remember`
      // argument of its own, so it must be passed in rather than closed
      // over from this outer scope.
      const finishLogin = async (result, connection) => {
        const { ip: connIp, username: connUsername, remember: connRemember } = connection;

        // What we persist is the server's remember-token, never the password.
        // It is bound to this device, expires on its own, and dies when the
        // device is removed or the password changes.
        const account = credentialAccountKey(connIp, connUsername);
        if (connRemember && result.rememberToken) {
          await saveSecret(account, result.rememberToken).catch(() => {});
        } else if (!connRemember) {
          await deleteSecret(account).catch(() => {});
        }
        // A resumed session doesn't get a fresh rememberExpiresAt back — the
        // server deliberately doesn't reissue or reset it on resume (see
        // RcApi.resume's doc comment), so the existing countdown is still
        // accurate. Keep it instead of clobbering it with null, as long as
        // it's for this same account.
        const preservedExpiresAt =
          orgConnection?.ip === connIp && orgConnection?.username === connUsername
            ? orgConnection.rememberExpiresAt ?? null
            : null;
        const nextConnection = {
          ip: connIp,
          username: connUsername,
          remember: connRemember,
          rememberExpiresAt: connRemember
            ? result.rememberExpiresAt ?? preservedExpiresAt
            : null,
        };
        setOrgConnection(nextConnection);
        saveJson(ORG_CONNECTION_KEY, nextConnection);

        // Silent re-login on token expiry (401): retried once by client.js,
        // resuming from the stored remember-token rather than a password.
        api.onAuthExpired = async () => {
          const rememberToken = await loadSecret(account).catch(() => null);
          if (!rememberToken) return null;
          const resumed = await api.resume({
            username: connUsername, rememberToken, deviceId,
          });
          if (resumed.ok) return resumed.token;
          // Only a 401 means the server actually rejected the token as dead
          // (expired, revoked, wrong device). Every other failure — a
          // network blip, a 429 throttle, a pin mismatch — says nothing
          // about the token's validity, so it must be left alone: deleting
          // on those would turn a transient hiccup into a permanent local
          // logout (see cambium-core/src/auth.rs's `an_unreachable_server_
          // keeps_the_remember_token` / `a_server_error_on_resume_keeps_the_
          // remember_token`, which this mirrors).
          if (resumed.status === 401) {
            await deleteSecret(account).catch(() => {});
          }
          return null;
        };

        const nextSession = { api, user: result.user, org: result.org };
        setSession(nextSession);
        setLoginSeq((seq) => seq + 1);

        // Fetch projects and auto-select one (last-used for this org, else first).
        const projectsResult = await api.listProjects();
        const list = projectsResult.ok ? projectsResult.projects ?? [] : [];
        setProjects(list);
        const lastUsed = loadJson(LAST_PROJECT_KEY, {})[api.baseUrl];
        const next = list.find((p) => p.id === lastUsed)?.id ?? list[0]?.id ?? null;
        api.setActiveProject(next);
        setActiveProjectId(next);

        // If the auto-selected project already has a workspace folder, the
        // caller can skip the Projects page and drop straight into it. Read
        // the persisted map rather than the `workspaces` state so this
        // callback doesn't need the state in its dependency list.
        const savedWorkspaces = loadJson(WORKSPACES_KEY, {});
        return { ok: true, autoOpened: Boolean(next && savedWorkspaces[next]) };
      };

      // A remembered connection submits with an empty password box (see
      // ConnectionsModal's placeholder): try the stored device-bound token
      // before falling back to treating this as an ordinary — and, with
      // nothing typed, invalid — password login.
      const account = credentialAccountKey(trimmedIp, trimmedUser);
      if (!password) {
        const rememberToken = await loadSecret(account).catch(() => null);
        if (!rememberToken) {
          return { ok: false, error: 'Enter your password.' };
        }
        const resumed = await api.resume({ username: trimmedUser, rememberToken, deviceId });
        if (resumed.ok) {
          // Fall through to the same post-login wiring as a password login,
          // reusing `resumed` in place of `result`. Honor the checkbox as
          // submitted here, not just "there was a token to resume with" —
          // an unticked "Remember me" must still forget the device (finishLogin
          // deletes the stored credential when `remember` is falsy).
          return finishLogin(resumed, { ip: trimmedIp, username: trimmedUser, remember });
        }
        // Pin mismatch is a hard stop, same as the password path below — it
        // means the server's identity changed, not that the token is bad.
        // Don't let it render as an inline "expired" message, and don't
        // delete a token that might still be perfectly valid on the real
        // server.
        if (resumed.pinMismatch) {
          setPinMismatch({ host });
          return { ok: false, error: resumed.error };
        }
        // Only a 401 means the server actually rejected the token as dead.
        // A network failure (no `status` at all) or a 429 throttle says
        // nothing about the token's validity — deleting on those would turn
        // a server restart or a rate limit into a permanent local logout
        // (see cambium-core/src/auth.rs's `an_unreachable_server_keeps_the_
        // remember_token` / `a_server_error_on_resume_keeps_the_remember_
        // token`, which this mirrors).
        if (resumed.status === 401) {
          await deleteSecret(account).catch(() => {});
          return { ok: false, error: 'Your saved sign-in expired. Enter your password.' };
        }
        return { ok: false, error: resumed.error ?? 'Could not reach the server. Is it running?' };
      }

      const result = await api.login({
        username: trimmedUser,
        password,
        deviceId,
        deviceName: hostName,
        remember,
      });
      if (!result.ok) {
        // Pin mismatch is a hard stop, distinct from an ordinary login error:
        // ConnectionsModal renders the mismatch modal instead of (or in
        // addition to) the inline form error.
        if (result.pinMismatch) setPinMismatch({ host });
        return { ok: false, error: result.error };
      }

      // Credentials are valid but this account is at its device limit on a new
      // device. Don't dead-end — open the device manager (api is already
      // authenticated with the returned token) so the user can free a slot.
      if (result.deviceLimitReached) {
        api.token = result.token ?? api.token;
        setDeviceLimit({
          api,
          devices: result.devices ?? [],
          maxDevices: result.maxDevices ?? 0,
          creds: { ip: trimmedIp, username: trimmedUser, password, remember },
        });
        return { ok: false, deviceLimitReached: true };
      }

      return finishLogin(result, { ip: trimmedIp, username: trimmedUser, remember });
    },
    [deviceId, hostName, orgConnection]
  );

  // Startup silent resume. A remembered connection stores a device-bound
  // token, not a password, so it can be redeemed with nothing typed: opening
  // the app drops the user straight into their workspace. Runs once on boot,
  // deliberately against the values captured at first render — `login` and
  // `orgConnection` both change identity as it proceeds, and re-running this
  // on either would re-attempt the login on top of the session it just made.
  useEffect(() => {
    if (!startupPending || startupAttempted.current) return;
    // StrictMode runs every effect twice in development, and the ref survives
    // that simulated remount where a local flag would not. It has to be a ref
    // for the same reason it has to be here at all: redeeming the token is a
    // server-side act — it registers a device against the account's limit —
    // so a second attempt is not free even though the first one's result gets
    // thrown away. Guarding the state updates instead of the call itself is
    // what the first cut of this did, and it fired twice.
    startupAttempted.current = true;
    let settled = false;
    let timer;
    // Both ends of the attempt land here, so the app can never be left
    // waiting on it: whichever gets there first wins and the UI moves on.
    // Deliberately not gated on an unmount flag — this provider lives as long
    // as the app does, and under StrictMode a cleanup that suppressed these
    // would strand the one surviving attempt with `startupPending` stuck true
    // and the window showing "Signing you in…" forever.
    const finish = (error) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      if (error) setStartupError(error);
      setStartupPending(false);
    };
    // Nothing in the API client bounds a request, and a host that swallows
    // packets rather than refusing them stalls for however long the OS takes
    // to give up. Waiting that out behind an empty window is worse than
    // asking for a password, so the form gets presented and the resume is
    // left to land (or not) behind it.
    timer = setTimeout(
      () => finish('Your saved sign-in is taking too long. Log in to continue.'),
      STARTUP_RESUME_TIMEOUT_MS
    );
    (async () => {
      const { ip, username } = orgConnection ?? {};
      // Ask the credential store before the network. `remember` is only a
      // stored preference, and login() with an empty password and no token
      // behind it answers "Enter your password." — an error about a form the
      // user hasn't been shown yet. Nothing stored simply means nothing to
      // resume, which is not a failure worth reporting.
      const account = credentialAccountKey(ip ?? '', username ?? '');
      const rememberToken = ip && username ? await loadSecret(account).catch(() => null) : null;
      if (!rememberToken) {
        finish(null);
        return;
      }
      const result = await login({ ip, username, password: '', remember: true });
      // trustRequired, pinMismatch and deviceLimitReached each raise their own
      // modal over the Login page (same rule as the form's own submit), so an
      // inline message alongside them would only be noise.
      const quiet =
        result.ok || result.trustRequired || result.pinMismatch || result.deviceLimitReached;
      finish(quiet ? null : result.error ?? 'Could not resume your saved sign-in.');
    })();
    return () => clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Device-limit modal: remove one of the account's devices (force-checks-in
  // whatever it holds), then refresh the list in place.
  const removeLimitDevice = useCallback(
    async (id) => {
      if (!deviceLimit) return { ok: false, error: 'No device manager open.' };
      const res = await deviceLimit.api.removeDevice(id);
      if (res.ok) {
        const list = await deviceLimit.api.getDevices('self');
        if (list.ok) {
          setDeviceLimit((prev) =>
            prev ? { ...prev, devices: list.devices ?? [], maxDevices: list.maxDevices ?? prev.maxDevices } : prev
          );
        }
      }
      return res;
    },
    [deviceLimit]
  );

  // "Done": drop the temporary manager session and log in for real, which now
  // has room to register this device.
  const finishDeviceLogin = useCallback(async () => {
    if (!deviceLimit) return { ok: false };
    const { creds, api } = deviceLimit;
    api.logout();
    setDeviceLimit(null);
    return login(creds);
  }, [deviceLimit, login]);

  const cancelDeviceLogin = useCallback(() => {
    deviceLimit?.api.logout();
    setDeviceLimit(null);
  }, [deviceLimit]);

  // Trust-on-first-use resolution. acceptTrust pins the fingerprint shown in
  // the prompt and re-runs login with the credentials captured at prompt
  // time — the pin is now stored, so this second pass proceeds normally.
  const acceptTrust = useCallback(async () => {
    if (!trustPrompt) return { ok: false };
    const { creds, cert } = trustPrompt;
    setPin(hostFromIp(creds.ip), cert?.fingerprint);
    setTrustPrompt(null);
    return login(creds);
  }, [trustPrompt, login]);

  const declineTrust = useCallback(() => setTrustPrompt(null), []);

  // The mismatch modal's only way out: drop the stored pin so the next login
  // attempt goes back through first-connect trust. Deliberately no
  // "continue anyway" alongside this.
  const forgetServerIdentity = useCallback((host) => {
    removePin(host);
    setPinMismatch(null);
  }, []);

  const dismissPinMismatch = useCallback(() => setPinMismatch(null), []);

  const logout = useCallback(() => {
    session?.api.logout();
    if (orgConnection) {
      // The CLI and this app share one credential store (see secrets.rs's
      // module doc). `cambium logout` already deletes the stored credential;
      // this face must do the same for the same account, or "remember me"
      // silently survives a logout done from here.
      const account = credentialAccountKey(orgConnection.ip, orgConnection.username);
      deleteSecret(account).catch(() => {});
      // The credential is gone: don't leave the login form advertising a
      // countdown for a token that no longer exists (same fix as
      // changePassword, below). Persist the clear too, not just the
      // in-memory state — otherwise a restart re-hydrates the stale
      // countdown straight back out of localStorage. Clear `remember`
      // itself as well: unlike changePassword (session stays open,
      // password rotates), logout deletes the credential outright, so a
      // persisted `remember: true` would still be lying about a token
      // that no longer exists. Stay within the same four-key shape the
      // hydration initializer allowlists.
      const cleared = {
        ip: orgConnection.ip,
        username: orgConnection.username,
        remember: false,
        rememberExpiresAt: null,
      };
      setOrgConnection(cleared);
      saveJson(ORG_CONNECTION_KEY, cleared);
    }
    setSession(null);
    setProjects([]);
    setActiveProjectId(null);
  }, [session, orgConnection]);

  const joinProject = useCallback(
    async (code) => {
      if (!session) return { ok: false, error: 'Not logged in.' };
      const result = await session.api.joinProject(code);
      if (!result.ok) return result;
      // Add (or refresh) the project and make it active.
      const project = result.project;
      setProjects((prev) =>
        prev.some((p) => p.id === project.id)
          ? prev.map((p) => (p.id === project.id ? project : p))
          : [...prev, project]
      );
      selectProject(project.id);
      return { ok: true, project };
    },
    [session, selectProject]
  );

  // Set the workspace folder for a project (defaults to the active one).
  // Persisted per project id, so each card remembers its own folder.
  const setWorkspacePath = useCallback(
    (path, projectId = activeProjectId) => {
      if (!projectId) return;
      setWorkspaces((prev) => ({ ...prev, [projectId]: path }));
    },
    [activeProjectId]
  );

  const workspacePath = activeProjectId ? workspaces[activeProjectId] ?? '' : '';

  const account = useMemo(() => {
    if (!session) return null;
    const { user } = session;
    return {
      username: user.username,
      displayName: user.displayName,
      avatarUrl: session.api.avatarUrl(user.avatar),
      timezone: user.timezone,
      status: user.status ?? '',
      role: user.role ?? 'member',
      devices: user.devices ?? [],
      deviceName:
        (user.devices ?? []).find((d) => d.deviceId === deviceId)?.name ?? hostName,
    };
  }, [session, deviceId, hostName]);

  const applyUserResult = useCallback((result) => {
    if (result.ok && result.user) {
      setSession((prev) => (prev ? { ...prev, user: result.user } : prev));
    }
    return result;
  }, []);

  const updateAccount = useCallback(
    async (patch) => {
      if (!session) return { ok: false, error: 'Not connected.' };
      return applyUserResult(await session.api.patchAccount(patch));
    },
    [session, applyUserResult]
  );

  const changePassword = useCallback(
    async (oldPassword, newPassword) => {
      if (!session) return { ok: false, error: 'Not connected.' };
      const result = await session.api.changePassword(oldPassword, newPassword);
      if (result.ok) {
        // The server revoked every remember-token for this account as part
        // of the change, so the stored one is already dead. Drop it and let
        // the next start ask for the new password once. Persist the clear
        // (not just in-memory state), same fix as logout, above. Unlike
        // logout, the session stays open here and the user's "remember me"
        // preference hasn't changed — only the token did — so `remember`
        // itself is left as-is; the next real login simply issues a fresh
        // token. Stay within the same four-key shape the hydration
        // initializer allowlists.
        const account = credentialAccountKey(orgConnection.ip, orgConnection.username);
        await deleteSecret(account).catch(() => {});
        const cleared = {
          ip: orgConnection.ip,
          username: orgConnection.username,
          remember: orgConnection.remember,
          rememberExpiresAt: null,
        };
        setOrgConnection(cleared);
        saveJson(ORG_CONNECTION_KEY, cleared);
      }
      return result;
    },
    [session, orgConnection]
  );

  const updateDeviceName = useCallback(
    async (name) => {
      if (!session) return { ok: false, error: 'Not connected.' };
      return applyUserResult(await session.api.renameDevice(name));
    },
    [session, applyUserResult]
  );

  const uploadAvatar = useCallback(
    async (dataUrl) => {
      if (!session) return { ok: false, error: 'Not connected.' };
      return applyUserResult(await session.api.uploadAvatar(dataUrl));
    },
    [session, applyUserResult]
  );

  const value = useMemo(
    () => ({
      // Persisted org connection (prefills Login page).
      orgConnection,
      // Boot-time silent resume: in flight, and why it failed if it did.
      startupPending,
      startupError,
      // Session / org identity.
      loggedIn,
      projectReady,
      orgName: session?.org?.name ?? null,
      orgTimezone: session?.org?.timezone ?? null,
      membersScope: session?.org?.membersScope ?? 'project',
      availabilityScope: session?.org?.availabilityScope ?? 'project',
      // Logged out there is no org, so no Organization tab. Logged in, a
      // server that doesn't send the flag keeps the old default (enabled).
      organizationEnabled: session ? session.org?.organizationEnabled ?? true : false,
      api: session?.api ?? null,
      account,
      deviceId,
      loginSeq,
      // Projects.
      projects,
      activeProjectId,
      activeProject,
      projectName: activeProject?.name ?? null,
      workspacePath,
      workspacePaths: workspaces,
      // Device-limit resolution flow.
      deviceLimit,
      removeLimitDevice,
      finishDeviceLogin,
      cancelDeviceLogin,
      // Trust-on-first-use flow.
      trustPrompt,
      pinMismatch,
      acceptTrust,
      declineTrust,
      forgetServerIdentity,
      dismissPinMismatch,
      // Actions.
      login,
      logout,
      selectProject,
      joinProject,
      refreshProjects,
      setWorkspacePath,
      updateAccount,
      updateDeviceName,
      changePassword,
      uploadAvatar,
    }),
    [
      orgConnection,
      startupPending,
      startupError,
      loggedIn,
      projectReady,
      session,
      account,
      deviceId,
      loginSeq,
      projects,
      activeProjectId,
      activeProject,
      workspacePath,
      workspaces,
      deviceLimit,
      removeLimitDevice,
      finishDeviceLogin,
      cancelDeviceLogin,
      trustPrompt,
      pinMismatch,
      acceptTrust,
      declineTrust,
      forgetServerIdentity,
      dismissPinMismatch,
      login,
      logout,
      selectProject,
      joinProject,
      refreshProjects,
      setWorkspacePath,
      updateAccount,
      updateDeviceName,
      changePassword,
      uploadAvatar,
    ]
  );

  return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>;
}

export function useConnection() {
  const ctx = useContext(ConnectionContext);
  if (!ctx) throw new Error('useConnection must be used within ConnectionProvider');
  return ctx;
}
