// Auth & session — deviceless (web) email-code sign-in against /auth/*, token
// storage, and the Authorization header all live HTTP clients (chat_api /
// graph_api / install_skill) attach.
//
// The backend has no passwords. Installed clients enroll a device keypair, but a
// browser is a "deviceless" client: it signs in purely with an emailed code via
// the web endpoints, and the only thing it persists is the returned session
// token. No keypair, no WebCrypto, no signing.
//   register/web/start {email, kind:'org'} → 6-digit code → register/web/verify
//   {email, code} → {access_token, account}
// register/web/verify both CREATES a new account and signs in an existing one,
// so to keep this console "enroll into an EXISTING org only, never create" we
// first look the email up with login/challenge, which answers both questions we
// need before mailing anything: 404 → no such account → refuse, and 200 →
// {kind} → refuse anything that isn't an org.
//
// This console is ORG-ONLY: it signs in organization accounts and nothing else.
// The lookup keeps non-org accounts from ever receiving a code; the post-verify
// account.kind === 'org' check stays as a backstop (older backend without
// `kind`, or an account that changed kind mid-flow).
//
// Session is persisted in localStorage (consistent with parcle.theme/route).
// Mock mode (no PARCLE_API_BASE) needs no login.
//
// Exposed on window:
//   parcleLiveAuthRequired()              — true when a real backend is configured
//   parcleToken() / parcleAccount()
//   parcleAuthHeaders()                   — {Authorization} or {} for fetch merges
//   parcleRestoreSession()                — {token, account} | null
//   parcleWebStart/Resend/Verify          — email-code sign-in (deviceless web)
//   parcleLogout()                        — POST /auth/logout (best-effort) + clear
//   parcleClearSession()
//   parcleResetAccountScopedState()       — drop the caches/streams of the outgoing account
//   parcleOnUnauthorized(cb) / parcleNotifyUnauthorized()  — 401 → back to login
//   <LoginGate onLogin={fn}/>             — the org email + verification-code form

const { useState: useAuthState, useEffect: useAuthEffect } = React;

const PARCLE_TOKEN_KEY   = 'parcle.authToken';
const PARCLE_ACCOUNT_KEY = 'parcle.account';
// Legacy: written by an older build that had an org/user mode switch. Nothing
// reads it any more; still cleared on sign-out so stale copies don't linger.
const PARCLE_MODE_KEY    = 'parcle.mode';

function parcleApiBase() { return window.PARCLE_API_BASE || ''; }
function parcleLiveAuthRequired() { return !!window.PARCLE_API_BASE; }

function parcleToken()   { try { return localStorage.getItem(PARCLE_TOKEN_KEY) || null; } catch (e) { return null; } }
function parcleAccount() {
  try {
    const raw = localStorage.getItem(PARCLE_ACCOUNT_KEY);
    return raw ? JSON.parse(raw) : null;
  } catch (e) { return null; }
}

// Header object to spread into fetch headers. Empty when unauthenticated so
// mock mode and the no-auth endpoints (/files, /skill/events, /skill/skill.md)
// keep working unchanged.
function parcleAuthHeaders() {
  const t = parcleToken();
  return t ? { Authorization: 'Bearer ' + t } : {};
}

// Drop every app-wide cache and live connection that holds ONE account's data:
// the conversation list (chat_api.jsx), the graph snapshot and the /skill/events
// stream (graph_api.jsx), the selected conversation id (chat.jsx).
//
// Signing out does not reload the page — it swaps the tree for LoginGate — so
// all of that module state outlives the session. Those caches are
// application-lifetime and already resolved, so the next account would be
// served the previous one's data without a single request ever going out under
// the new token. This hangs off the two session chokepoints below rather than
// the sign-in / sign-out call sites, so a future auth path cannot forget it.
//
// Every call is guarded: modules are optional (mock builds) and script load
// order is not guaranteed relative to this file.
function parcleResetAccountScopedState() {
  try { if (typeof invalidateConversations === 'function') invalidateConversations(); } catch (e) { /* isolate */ }
  try { if (typeof resetGraphAccountState === 'function') resetGraphAccountState(); } catch (e) { /* isolate */ }
  try { if (typeof clearChatSelection === 'function') clearChatSelection(); } catch (e) { /* isolate */ }
}

function parcleSetSession({ token, account }) {
  // Before the new token is readable anywhere: whatever is cached belongs to
  // the account being replaced, even when it is the same account signing back
  // in (its data may have moved on since).
  parcleResetAccountScopedState();
  try {
    localStorage.setItem(PARCLE_TOKEN_KEY, token);
    localStorage.setItem(PARCLE_ACCOUNT_KEY, JSON.stringify(account));
  } catch (e) { /* storage disabled — session lives only for this load */ }
}

function parcleClearSession() {
  try {
    localStorage.removeItem(PARCLE_TOKEN_KEY);
    localStorage.removeItem(PARCLE_ACCOUNT_KEY);
    localStorage.removeItem(PARCLE_MODE_KEY);
  } catch (e) { /* ignore */ }
  // Sign-out and 401 both land here. Clearing now (rather than only on the
  // next sign-in) also means the signed-out LoginGate holds no org data.
  parcleResetAccountScopedState();
}

function parcleRestoreSession() {
  const token = parcleToken();
  const account = parcleAccount();
  if (token && account) return { token, account };
  return null;
}

// ── Demo fake sign-in ──
// Whitelisted emails (config.demo.whitelist) sign into the shared demo org
// using a pre-minted org token (config.demo.orgToken) with NO backend auth —
// so their own real accounts (same email, other apps / CLI) are never touched.
// The entered email is a display-only facade; all data is the demo org's
// because the token is org-scoped. Disabled unless orgToken is set (see
// config.js / config.local.js).
function parcleDemoConfig() {
  const d = window.PARCLE_DEMO;
  return (d && typeof d === 'object' && d.orgToken && Array.isArray(d.whitelist)) ? d : null;
}
function parcleDemoWhitelisted(email) {
  const d = parcleDemoConfig();
  if (!d) return false;
  const e = (email || '').trim().toLowerCase();
  return !!e && d.whitelist.some(w => String(w).trim().toLowerCase() === e);
}
// Mirror of the backend's display-name derivation (john.doe@x → "John Doe").
function _parcleNameFromEmail(email) {
  const local = (email || '').split('@')[0] || 'there';
  return local.replace(/[._+-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).slice(0, 60);
}
function parcleDemoSignIn(email) {
  const d = parcleDemoConfig();
  const addr = (email || '').trim();
  const account = { id: d.orgAccountId, kind: 'org', email: addr, display_name: _parcleNameFromEmail(addr) };
  const session = { token: d.orgToken, account };
  parcleSetSession(session);
  return session;
}
function parcleIsDemoToken(token) {
  const d = parcleDemoConfig();
  return !!(d && token && token === d.orgToken);
}

async function _parcleParseDetail(res) {
  let msg = 'HTTP ' + res.status;
  try {
    const j = await res.json();
    if (j && j.detail) msg = typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail);
  } catch (e) { /* non-JSON body — keep generic message */ }
  return msg;
}

async function _parcleErr(res, stage) {
  const err = new Error(await _parcleParseDetail(res));
  err.status = res.status;
  err.stage = stage;
  return err;
}

// This console is organization-only. Shown both by the pre-code lookup (the
// normal path — no code is ever sent to a personal account) and by the
// post-verify guard below.
function _parcleNotOrgMessage() {
  return `This is a personal account — the ${parcleBrand().name} console is for organization workspaces only.`;
}

// Backstop for the pre-code lookup: the backend web verify endpoint is
// kind-agnostic, so a personal (user) account could otherwise sign in and get
// mislabeled as org — failing later on org-only /skill/* calls. Reject it and
// DON'T store a session (caller surfaces err.message; err.code='not_org').
function _parcleRequireOrg(account) {
  if (!account || account.kind !== 'org') {
    const err = new Error(_parcleNotOrgMessage());
    err.code = 'not_org';
    throw err;
  }
}

// Best-effort revoke a freshly-issued token we've decided NOT to keep (e.g. a
// non-org account that web/verify signed in anyway). Fire-and-forget so the
// caller can throw immediately; without this the backend would leave a live,
// usable session behind even though we never stored it locally.
function _parcleRevokeToken(token) {
  if (!parcleApiBase() || !token) return;
  try {
    fetch(parcleApiBase() + '/auth/logout', {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + token },
    }).catch(() => { /* offline / already-expired — nothing else to do */ });
  } catch (e) { /* fetch threw synchronously — ignore */ }
}

// ── Account lookup (existence + kind) ──
// register/web/verify CREATES an account when the email is new, and signs in a
// personal account just as happily as an org one. To keep this console "sign
// into an existing ORG only", we ask login/challenge first: it returns a nonce
// (200) plus the account's `kind` for a known email, and 404 for an unknown one.
// We never complete the challenge (the nonce just expires); we use it purely as
// a lookup. Knowing the kind BEFORE web/start is what lets us turn a personal
// account away without mailing it a code it can never use.
// Returns {exists, kind} — kind is null when the account does not exist.
async function _parcleLookupAccount(email) {
  const res = await fetch(parcleApiBase() + '/auth/login/challenge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  });
  if (res.status === 404) return { exists: false, kind: null };
  if (!res.ok) throw await _parcleErr(res, 'lookup');
  let kind = null;
  try {
    const j = await res.json();
    kind = j && typeof j.kind === 'string' ? j.kind : null;
  } catch (e) { /* older backend without `kind` — fall through to the post-verify guard */ }
  return { exists: true, kind };
}

// ── Deviceless web sign-in (email code) ──
// web/start stashes a pending registration (no device fields) and emails a code;
// web/verify confirms the code and returns a deviceless session — creating the
// account if new, else signing the existing one in. In dev mode (no mail
// provider) start/resend echo the code as `dev_code`.
async function parcleWebStart(email) {
  const res = await fetch(parcleApiBase() + '/auth/register/web/start', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, kind: 'org' }),
  });
  if (!res.ok) throw await _parcleErr(res, 'web-start');
  return await res.json(); // {ok, email, expires_in_seconds, dev_code?}
}

async function parcleWebResend(email) {
  const res = await fetch(parcleApiBase() + '/auth/register/resend', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  });
  if (!res.ok) throw await _parcleErr(res, 'web-resend');
  return await res.json();
}

async function parcleWebVerify(email, code) {
  const res = await fetch(parcleApiBase() + '/auth/register/web/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, code }),
  });
  if (!res.ok) throw await _parcleErr(res, 'web-verify');
  const data = await res.json();
  const account = data.account || null;
  if (!data.access_token || !account) throw new Error('Malformed verify response');
  // Org-only console. The backend already issued a usable token for this email
  // regardless of kind — if it's not an org account, revoke that token before
  // rejecting so no live session is left behind, then throw.
  if (!account || account.kind !== 'org') _parcleRevokeToken(data.access_token);
  _parcleRequireOrg(account);
  const session = { token: data.access_token, account };
  parcleSetSession(session);
  return session;
}

function parcleLogout() {
  // Clear the local session FIRST and synchronously: capture the token, wipe
  // storage now, then fire the server-side revoke as fire-and-forget. Awaiting
  // the request before clearing would race a fast sign-out → sign-in (the late
  // clear could wipe the freshly-issued token, leaving the next /skill/* call
  // tokenless → 401 → bounced back to login).
  const headers = parcleAuthHeaders();
  const wasDemo = parcleIsDemoToken(parcleToken());
  parcleClearSession();
  // Never revoke the SHARED demo org token on logout — every other demo user
  // is relying on that same token. Demo logout just clears local state.
  if (!wasDemo && parcleApiBase() && headers.Authorization) {
    try {
      fetch(parcleApiBase() + '/auth/logout', { method: 'POST', headers })
        .catch(() => { /* offline / already-expired — local clear already done */ });
    } catch (e) { /* fetch threw synchronously — ignore, session is already cleared */ }
  }
}

// 401 channel: live clients call parcleNotifyUnauthorized() when a token is
// rejected; App subscribes to drop its session state and bounce to login.
const _parcleUnauthorizedSubs = new Set();
function parcleOnUnauthorized(cb) {
  _parcleUnauthorizedSubs.add(cb);
  return () => { _parcleUnauthorizedSubs.delete(cb); };
}
function parcleNotifyUnauthorized() {
  parcleClearSession();
  _parcleUnauthorizedSubs.forEach(cb => { try { cb(); } catch (e) { /* isolate */ } });
}

// ──────────────────────────────────────────────────────────────────────────
// LoginGate — shown by App in live mode until a session exists. Org-only and
// passwordless: enter the organization email and we send a 6-digit code.
//   • If no org account exists for the email → we refuse (orgs are provisioned
//     elsewhere; the console never creates them).
//   • Otherwise → email a code → verify → sign in. A personal (user) account is
//     rejected after verify with a clear message.
// ──────────────────────────────────────────────────────────────────────────
function LoginGate({ onLogin }) {
  const [view, setView] = useAuthState('email');   // 'email' | 'verify'
  const [email, setEmail] = useAuthState('');
  const [code, setCode] = useAuthState('');
  const [busy, setBusy] = useAuthState(false);
  const [error, setError] = useAuthState(null);
  const [info, setInfo] = useAuthState(null);
  const [resendIn, setResendIn] = useAuthState(0);  // seconds left before resend allowed

  // Resend cooldown ticker.
  useAuthEffect(() => {
    if (resendIn <= 0) return undefined;
    const t = setTimeout(() => setResendIn(resendIn - 1), 1000);
    return () => clearTimeout(t);
  }, [resendIn]);

  const clearMsgs = () => { setError(null); setInfo(null); };

  const inputStyle = {
    height: 36, padding: '0 10px', fontSize: 13,
    border: '1px solid var(--border)', borderRadius: 8,
    background: 'var(--surface)', color: 'var(--text-primary)', outline: 'none',
  };

  const codeSentMsg = (r, addr) =>
    r && r.dev_code ? `Dev mode — your code is ${r.dev_code}` : `We sent a 6-digit code to ${addr}.`;

  // The address the rest of the flow uses. Account lookup on the backend is
  // case-sensitive, so we submit what the user typed first and only retry
  // lower-cased (below) — folding every address unconditionally would lock out
  // accounts that were registered with mixed case.
  const [resolved, setResolved] = useAuthState('');

  // Enter email → refuse unknown orgs, else email a code and move to verify.
  const submitEmail = async (e) => {
    e.preventDefault();
    if (busy) return;
    setBusy(true); clearMsgs();
    const typed = email.trim();
    try {
      // Demo fake sign-in: whitelisted emails go straight into the shared demo
      // org (no code, no backend auth). Their real accounts stay untouched.
      if (parcleDemoWhitelisted(typed)) {
        onLogin(parcleDemoSignIn(typed));
        return;  // LoginGate unmounts on login; leaving busy=true is fine.
      }
      // Try as typed, then lower-cased. An org login name we hand out (e.g.
      // "mithra.co") is stored lower-case, so a user typing "Mithra.CO" still
      // lands on the same account instead of "organization not found".
      let addr = typed;
      let found = await _parcleLookupAccount(addr);
      if (!found.exists && typed !== typed.toLowerCase()) {
        addr = typed.toLowerCase();
        found = await _parcleLookupAccount(addr);
      }
      if (!found.exists) {
        setError(`No ${parcleBrand().name} organization found for this email.`);
        setBusy(false);
        return;
      }
      // Org-only console: a personal account is turned away HERE, before
      // web/start mails it a code it could never complete sign-in with.
      // found.kind is null only against a backend that predates the kind field —
      // then we fall through and the post-verify guard catches it.
      if (found.kind && found.kind !== 'org') {
        setError(_parcleNotOrgMessage());
        setBusy(false);
        return;
      }
      setResolved(addr);
      const r = await parcleWebStart(addr);
      setView('verify');
      setResendIn(60);
      setInfo(codeSentMsg(r, addr));
    } catch (err) {
      setError(err.message || 'Sign-in failed');
    }
    setBusy(false);
  };

  const submitVerify = async (e) => {
    e.preventDefault();
    if (busy) return;
    setBusy(true); clearMsgs();
    try {
      const session = await parcleWebVerify(resolved || email.trim(), code.trim());
      onLogin(session);
    } catch (err) {
      setError(err.message || 'Verification failed');
      setBusy(false);
    }
  };

  const resend = async () => {
    if (resendIn > 0 || busy) return;
    clearMsgs();
    const addr = resolved || email.trim();
    try {
      const r = await parcleWebResend(addr);
      setResendIn(60);
      setInfo(codeSentMsg(r, addr));
    } catch (err) {
      setError(err.message || 'Could not resend code');
    }
  };

  const brand = parcleBrand();
  const title = view === 'verify' ? 'Verify your email' : `Sign in to ${brand.name}`;
  const subtitle = view === 'verify'
    ? `Enter the code we sent to ${email.trim() || 'your email'}.`
    : 'Organization workspace.';
  const onSubmit = view === 'verify' ? submitVerify : submitEmail;

  const errorBox = error && (
    <div className="t-body" style={{
      fontSize: 12, color: 'var(--danger, #DC2626)',
      background: 'var(--danger-muted, rgba(220,38,38,.08))',
      border: '1px solid var(--danger-muted, rgba(220,38,38,.2))',
      borderRadius: 8, padding: '8px 10px',
    }}>{error}</div>
  );
  const infoBox = info && (
    <div className="t-body" style={{
      fontSize: 12, color: 'var(--accent-text, var(--text-secondary))',
      background: 'var(--accent-muted, rgba(0,0,0,.04))',
      border: '1px solid var(--border-subtle)',
      borderRadius: 8, padding: '8px 10px',
    }}>{info}</div>
  );

  return (
    <div style={{
      minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: 'var(--bg)', padding: 24,
    }}>
      <form onSubmit={onSubmit} style={{
        width: 360, background: 'var(--surface-raised)',
        border: '1px solid var(--border)', borderRadius: 14,
        boxShadow: 'var(--shadow-2)', overflow: 'hidden',
      }}>
        <div style={{ padding: '24px 24px 16px', borderBottom: '1px solid var(--border-subtle)' }}>
          <BrandMark size={32} radius={8} ink="var(--accent)" on="var(--on-accent)"
            style={{ marginBottom: 14 }}/>
          <h1 className="t-h1" style={{ margin: 0, fontSize: 20 }}>{title}</h1>
          <div className="text-secondary t-body" style={{ marginTop: 4, fontSize: 13 }}>{subtitle}</div>
          {/* plain text-tertiary, not t-micro: that class upper-cases, which
              would render the vendor's name as "POWERED BY PARCLE" */}
          {brand.poweredBy && (
            <div className="text-tertiary" style={{ marginTop: 10, fontSize: 11 }}>{brand.poweredBy}</div>
          )}
        </div>

        <div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 14 }}>
          {view === 'email' && (
            <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
              <span className="t-micro text-tertiary">Organization email or login name</span>
              {/* NOT type="email": a provisioned org signs in with a login name
                  (e.g. "mithra.co"), which the browser's built-in email
                  validation would reject before the form ever submits. */}
              <input type="text" autoFocus required value={email} onChange={e => setEmail(e.target.value)}
                inputMode="email" autoCapitalize="none" autoCorrect="off"
                autoComplete="username" style={inputStyle}/>
            </label>
          )}

          {view === 'verify' && (
            <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
              <span className="t-micro text-tertiary">Verification code</span>
              <input type="text" inputMode="numeric" autoFocus required value={code}
                onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
                placeholder="123456"
                style={{ ...inputStyle, letterSpacing: 6, fontSize: 16, textAlign: 'center' }}/>
            </label>
          )}

          {infoBox}
          {errorBox}

          <button type="submit" className="btn" disabled={busy}
            style={{ width: '100%', justifyContent: 'center', height: 38, opacity: busy ? 0.7 : 1 }}>
            {busy
              ? (view === 'verify' ? 'Verifying…' : 'Continuing…')
              : (view === 'verify' ? 'Verify & continue' : 'Continue')}
          </button>

          {view === 'verify' && (
            <button type="button" className="btn ghost" disabled={resendIn > 0 || busy}
              onClick={resend}
              style={{ width: '100%', justifyContent: 'center', height: 34, fontSize: 12 }}>
              {resendIn > 0 ? `Resend code in ${resendIn}s` : 'Resend code'}
            </button>
          )}

          {view === 'verify' && (
            <div className="t-small text-tertiary" style={{ textAlign: 'center', fontSize: 12 }}>
              <a href="#" onClick={e => { e.preventDefault(); clearMsgs(); setCode(''); setView('email'); }}
                style={{ color: 'var(--accent-text, var(--accent))' }}>Use a different email</a>
            </div>
          )}
        </div>
      </form>
    </div>
  );
}

Object.assign(window, {
  parcleApiBase, parcleLiveAuthRequired,
  parcleToken, parcleAccount, parcleAuthHeaders,
  parcleSetSession, parcleClearSession, parcleRestoreSession, parcleResetAccountScopedState,
  parcleWebStart, parcleWebResend, parcleWebVerify,
  parcleLogout,
  parcleDemoConfig, parcleDemoWhitelisted, parcleDemoSignIn, parcleIsDemoToken,
  parcleOnUnauthorized, parcleNotifyUnauthorized,
  LoginGate,
});
