import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import qrcodeGenerator from 'qrcode-generator';
import type { WidgetBootstrap } from './bootstrap';
import { WidgetApi, type RoomEvent } from './widget-api';
import { createT, type T } from './i18n';
import { parseEvent } from './bridge-protocol/parser';
import {
  hydrateFromTimeline,
  initialLoginState,
  loginReducer,
  type HydrateInput,
  type LoginErrorFlag,
} from './state';

// Visual canon mirrors the Telegram widget — Dawn palette, fleet-violet
// accent, monospace handles. The Discord widget keeps Vojo's accent (per
// product decision: «used Vojo style») rather than adopting Discord
// blurple, so the panel reads as a coherent continuation of the host UI.

type TranscriptKind = 'from-bot' | 'from-user' | 'diag' | 'error';

type TranscriptLine = {
  id: string;
  ts: number;
  kind: TranscriptKind;
  text: string;
};

type Props = {
  bootstrap: WidgetBootstrap;
  // The WidgetApi is constructed in main.tsx synchronously, BEFORE React's
  // first render — see widget-telegram for the cached-bundle race rationale.
  api: WidgetApi;
};

const TRANSCRIPT_MAX = 200;

// Inline SVG refresh icon — same as TG widget for visual consistency.
const RefreshIcon = () => (
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
    <path d="M3.5 8.5a6.5 6.5 0 0 1 11.4-3.2" stroke-linecap="round" />
    <path d="M16.5 11.5a6.5 6.5 0 0 1-11.4 3.2" stroke-linecap="round" />
    <path d="M14.6 3.2v3.5h-3.5" stroke-linecap="round" stroke-linejoin="round" />
    <path d="M5.4 16.8v-3.5h3.5" stroke-linecap="round" stroke-linejoin="round" />
  </svg>
);

// Inline SVG info icon — leads the AboutCard. Shared shape with the
// Telegram widget. WhatsApp uses a triangle warning glyph instead
// because its About modal also carries a Meta-ToS risk disclosure.
const InfoIcon = () => (
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
    <circle cx="10" cy="10" r="7.5" />
    <path d="M10 9.2 L10 14" stroke-linecap="round" />
    <circle cx="10" cy="6.4" r="0.7" fill="currentColor" stroke="none" />
  </svg>
);

// Three QR finder squares + a few module dots — leads the QR-login
// card. Same shape across all three bot widgets.
const QrIcon = () => (
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
    <rect x="3" y="3" width="5" height="5" rx="0.6" />
    <rect x="12" y="3" width="5" height="5" rx="0.6" />
    <rect x="3" y="12" width="5" height="5" rx="0.6" />
    <path
      d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
      stroke-linecap="round"
    />
  </svg>
);

// Sign-out arrow leaving an open box — leads the destructive logout
// card. Open right side conveys «out of the session». Stays muted
// inside `.command-card.danger` so the rose accent remains a single
// accent on the title.
const LogoutIcon = () => (
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
    <path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
    <line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
    <path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
  </svg>
);

// Two chain links joined by a horizontal bar — leads the Discord-
// only Reconnect card. Visually distinct from RefreshIcon's circular
// arrows so the user can tell «re-establish bridge link» from
// «re-fetch status» at a glance.
const LinkIcon = () => (
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
    <path d="M8 13 H5.5 a3 3 0 0 1 0 -6 H8" stroke-linecap="round" />
    <path d="M12 7 H14.5 a3 3 0 0 1 0 6 H12" stroke-linecap="round" />
    <line x1="7" y1="10" x2="13" y2="10" stroke-linecap="round" />
  </svg>
);

// 2×2 grid of rounded squares — leads the OpenSpaceCard. Reads as
// «space with channels inside»; consistent visual vocabulary with the
// channels-tab workspace grid affordances on the host side.
const SpaceGridIcon = () => (
  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
    <rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" />
    <rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" />
    <rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" />
    <rect x="11" y="11" width="5.5" height="5.5" rx="1.4" />
  </svg>
);

// Linkifier — same heuristic as TG widget.
const URL_RE = /https?:\/\/[^\s)]+/g;

// Defense-in-depth: a Discord remoteauth login URL is the LIVE login
// secret. Today the bridge only emits it via `m.image` (which we route
// to a generic «QR-код выдан» diag, never a verbatim transcript line).
// But if a future bridge revision started echoing the URL into m.notice
// — say, for a chat-fallback fallback path — the existing transcript
// append would (a) store the URL in the DOM, (b) survive page reload via
// the hydrate replay, and (c) the linkifier would turn it into a
// clickable anchor that opens in the parent browser, leaving the active
// login token in the user's history. Scrubbing here makes the leak
// path closed even if the upstream wiring drifts.
const REMOTEAUTH_URL_RE =
  /https:\/\/(?:[a-z0-9-]+\.)?(?:discordapp|discord)\.com\/(?:ra|login\/handoff)\/[A-Za-z0-9_\-+=.~?&/]+/gi;
const scrubLoginSecret = (body: string): string =>
  body.replace(REMOTEAUTH_URL_RE, '[redacted login URL]');

const formatTime = (ts: number): string => {
  const d = new Date(ts);
  const hh = String(d.getHours()).padStart(2, '0');
  const mm = String(d.getMinutes()).padStart(2, '0');
  const ss = String(d.getSeconds()).padStart(2, '0');
  return `${hh}:${mm}:${ss}`;
};

const renderBody = (body: string): ComponentChildren => {
  const out: ComponentChildren[] = [];
  let lastIndex = 0;
  for (const match of body.matchAll(URL_RE)) {
    const idx = match.index ?? 0;
    if (idx > lastIndex) out.push(body.slice(lastIndex, idx));
    out.push(
      <a key={`${idx}-${match[0]}`} href={match[0]} target="_blank" rel="noreferrer noopener">
        {match[0]}
      </a>
    );
    lastIndex = idx + match[0].length;
  }
  if (lastIndex < body.length) out.push(body.slice(lastIndex));
  return out.length === 0 ? body : out;
};

const localizeError = (err: LoginErrorFlag, t: T): string => {
  switch (err.kind) {
    case 'login_failed':
      return t('auth-error.login-failed', { reason: err.reason ?? '' });
    case 'captcha_required':
      return t('auth-error.captcha-required');
    case 'captcha_send_failed':
      return t('auth-error.captcha-send-failed');
    case 'captcha_expired':
      return t('auth-error.captcha-expired');
    case 'login_websocket_failed':
      return t('auth-error.websocket-failed', { reason: err.reason ?? '' });
    case 'connect_after_login_failed':
      return t('auth-error.connect-after-login-failed', { reason: err.reason ?? '' });
    case 'prepare_login_failed':
      return t('auth-error.prepare-failed', { reason: err.reason ?? '' });
    case 'already_logged_in':
      return t('auth-error.already-logged-in');
    case 'unknown_command':
      return t('auth-error.unknown-command');
    default: {
      const exhaustive: never = err;
      return String(exhaustive);
    }
  }
};

// Captcha is the only «not really an error, more of a suggestion» case —
// surface as warn (amber) rather than red. Everything else is a hard
// failure of the login attempt and gets red treatment.
const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
  if (err.kind === 'captcha_required') return 'warn';
  if (err.kind === 'captcha_expired') return 'warn';
  if (err.kind === 'already_logged_in') return 'warn';
  return 'error';
};

// --------------------------------------------------------------------------
// QR panel
// --------------------------------------------------------------------------

// Discord remoteauth's server-side timeout sits around 2 minutes of
// inactivity (the bridge holds the websocket; Discord's gateway closes
// it from its side). 3 minutes is a slight safety margin: the user
// sees «expired» a touch after the server probably already dropped
// the WS, but never before, so they can't trust a dead QR. This MUST
// match HYDRATE_FRESHNESS_MS in state.ts so the timeline-resume window
// agrees with the panel countdown — diverging the two would mean a
// reload at e.g. 4 min restores the panel even though the panel
// itself would render «expired». Telegram's MTProto QR rotates and
// lives ~10 min, which is why the TG widget uses 10 min for both.
const QR_TIMEOUT_MS = 3 * 60 * 1000;

// Error-correction level M is a good trade-off for short URLs — more
// resilient to camera glare than L, smaller modules than Q. typeNumber=0
// auto-picks the smallest QR version that fits the payload.
const buildQrModules = (data: string): boolean[][] | null => {
  if (!data) return null;
  try {
    const qr = qrcodeGenerator(0, 'M');
    qr.addData(data);
    qr.make();
    const count = qr.getModuleCount();
    const matrix: boolean[][] = [];
    for (let r = 0; r < count; r += 1) {
      const row: boolean[] = [];
      for (let c = 0; c < count; c += 1) {
        row.push(qr.isDark(r, c));
      }
      matrix.push(row);
    }
    return matrix;
  } catch {
    return null;
  }
};

// Render the QR matrix as <rect> elements inside an SVG. We deliberately
// avoid `dangerouslySetInnerHTML` and any external QR-rendering service:
// the `https://discord.com/ra/...` URL IS the login secret, so it must
// never leave the iframe and must never reach a stringified-HTML path
// that bypasses Preact's escaping.
type QrSvgProps = { matrix: boolean[][]; pixelSize: number; ariaLabel: string };
const QrSvg = ({ matrix, pixelSize, ariaLabel }: QrSvgProps) => {
  const count = matrix.length;
  const margin = 4;
  const totalUnits = count + margin * 2;
  const cellPx = pixelSize / totalUnits;
  const rects: ComponentChildren[] = [];
  for (let r = 0; r < count; r += 1) {
    for (let c = 0; c < count; c += 1) {
      if (!matrix[r][c]) continue;
      rects.push(
        <rect
          key={`${r}-${c}`}
          x={(c + margin) * cellPx}
          y={(r + margin) * cellPx}
          width={cellPx + 0.5 /* +0.5 px overlap kills subpixel gaps on Android */}
          height={cellPx + 0.5}
          fill="#000"
        />
      );
    }
  }
  return (
    <svg
      width={pixelSize}
      height={pixelSize}
      viewBox={`0 0 ${pixelSize} ${pixelSize}`}
      role="img"
      aria-label={ariaLabel}
    >
      {rects}
    </svg>
  );
};

type QrPanelProps = {
  state: {
    kind: 'awaiting_qr_scan';
    discordUrl: string;
    firstShownAt: number;
    lastError?: LoginErrorFlag;
  };
  t: T;
  onCancel: () => void;
};

const QrPanel = ({ state, t, onCancel }: QrPanelProps) => {
  const [now, setNow] = useState(() => Date.now());
  useEffect(() => {
    const timer = window.setInterval(() => setNow(Date.now()), 1000);
    return () => window.clearInterval(timer);
  }, []);

  const matrix = useMemo(() => buildQrModules(state.discordUrl), [state.discordUrl]);
  const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
  const remainingSeconds = Math.max(0, Math.ceil((QR_TIMEOUT_MS - elapsed) / 1000));
  const expired = elapsed >= QR_TIMEOUT_MS && state.firstShownAt > 0;

  return (
    <div class="auth-card auth-card-qr">
      <div class="auth-card-title">{t('auth-card.qr.title')}</div>
      <div class="auth-card-hint">{t('auth-card.qr.hint')}</div>
      <div class="auth-card-qr-frame">
        {matrix ? (
          // The aria-label describes the PURPOSE, not the contents — the
          // URL itself is the login secret and must not be exposed via
          // AT-tree text content.
          <QrSvg matrix={matrix} pixelSize={232} ariaLabel={t('auth-card.qr.aria')} />
        ) : (
          <div class="auth-card-qr-placeholder" role="status" aria-live="polite">
            <span class="dot" />
            {t('auth-card.qr.preparing')}
          </div>
        )}
      </div>
      {!expired ? (
        <div class="auth-card-countdown">
          {t('auth-card.qr.countdown', {
            minutes: String(Math.floor(remainingSeconds / 60)),
            seconds: String(remainingSeconds % 60).padStart(2, '0'),
          })}
        </div>
      ) : (
        <div class="auth-card-countdown expired">{t('auth-card.qr.expired')}</div>
      )}
      <ol class="auth-card-qr-steps">
        <li>{t('auth-card.qr.step-1')}</li>
        <li>{t('auth-card.qr.step-2')}</li>
        <li>{t('auth-card.qr.step-3')}</li>
      </ol>
      {state.lastError ? (
        <div class={errorTone(state.lastError) === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
          {localizeError(state.lastError, t)}
        </div>
      ) : null}
      <div class="auth-card-row">
        <button type="button" class="btn-text" onClick={onCancel}>
          {t('auth-card.cancel')}
        </button>
      </div>
    </div>
  );
};

// --------------------------------------------------------------------------
// Captcha panel — Vojo-patched bridge surfaces an hCaptcha challenge via
// the `VOJO-CAPTCHA-CHALLENGE-V1` notice. The panel lazy-loads hCaptcha's
// own JS, renders the challenge widget into our container with the supplied
// sitekey + rqdata (the latter is REQUIRED for hCaptcha-enterprise — Discord
// uses the enterprise tier), and on solve hands the token back to the App
// which sends `login-captcha <token>` to the bridge.
// --------------------------------------------------------------------------

// Canonical hCaptcha bootstrapping for explicit render: combine
// `?render=explicit` (don't auto-render any data-sitekey div) with
// `?onload=fnname` (call our resolver when the SDK is fully initialized).
// docs.hcaptcha.com/configuration recommends this over polling
// `window.hcaptcha` because (a) the global appears before its methods are
// safe to call in some SDK versions, (b) onload fires once and is cheaper
// than a 50ms tick. We still keep a wall-clock timeout as a backstop —
// covers ad-block / CSP / third-party-cookie blocks where the script
// itself fails to load.
const HCAPTCHA_ONLOAD_FN = '__vojoHCaptchaOnload';
const HCAPTCHA_API_URL = `https://js.hcaptcha.com/1/api.js?render=explicit&onload=${HCAPTCHA_ONLOAD_FN}`;
const HCAPTCHA_LOAD_TIMEOUT_MS = 15_000;

// hCaptcha render() does NOT accept rqdata as a config field — for
// hCaptcha-enterprise (which Discord uses) rqdata must be applied via
// `setData(widgetId, { rqdata })` AFTER `render()`. Passing rqdata to
// render is silently ignored, the resulting token isn't bound to the
// challenge, and Discord rejects with `invalid-response` /
// `sitekey-secret-mismatch`. Sources:
//   https://github.com/hCaptcha/vue-hcaptcha (search `setData(`)
//   https://github.com/hCaptcha/react-hcaptcha/issues/190
type HCaptchaConfig = {
  sitekey: string;
  callback?: (token: string) => void;
  'expired-callback'?: () => void;
  'error-callback'?: () => void;
  size?: 'normal' | 'compact' | 'invisible';
  theme?: 'light' | 'dark';
};
type HCaptchaApi = {
  render: (container: HTMLElement, config: HCaptchaConfig) => string;
  setData: (widgetId: string, data: { rqdata: string }) => void;
  reset: (id?: string) => void;
  remove: (id: string) => void;
};
declare global {
  interface Window {
    hcaptcha?: HCaptchaApi;
    [HCAPTCHA_ONLOAD_FN]?: () => void;
  }
}

let hcaptchaScriptPromise: Promise<HCaptchaApi> | null = null;

const loadHCaptcha = (): Promise<HCaptchaApi> => {
  if (typeof window === 'undefined') return Promise.reject(new Error('no window'));
  // Already initialised (e.g. earlier mount of the captcha panel) — skip
  // the script dance entirely.
  if (window.hcaptcha) return Promise.resolve(window.hcaptcha);
  if (hcaptchaScriptPromise) return hcaptchaScriptPromise;
  hcaptchaScriptPromise = new Promise<HCaptchaApi>((resolve, reject) => {
    const existing = document.querySelector(
      `script[src^="https://js.hcaptcha.com/1/api.js"]`
    ) as HTMLScriptElement | null;

    // `timeoutHandle` is read in the `settle` closure declared below
    // BEFORE the assignment at the bottom of this function. ESLint's
    // flow analysis can't see the deferred assignment through the
    // closure and flags this as never-reassigned; in practice the value
    // IS reassigned and using `const` here would break the hcaptcha
    // script-load timeout path.
    // eslint-disable-next-line prefer-const
    let timeoutHandle: number | undefined;
    let settled = false;
    const settle = (action: () => void) => {
      if (settled) return;
      settled = true;
      if (timeoutHandle !== undefined) window.clearTimeout(timeoutHandle);
      // Drop the global — leaves `window` clean if no other consumer
      // is hooked. Future `loadHCaptcha()` calls hit the early-return
      // `if (window.hcaptcha)` and skip this whole path.
      delete window[HCAPTCHA_ONLOAD_FN];
      action();
    };

    // `?onload=` calls our global once the SDK finishes initialising.
    // We register BEFORE appending the script so the listener is in
    // place if the script is already cached and runs synchronously.
    window[HCAPTCHA_ONLOAD_FN] = () => {
      if (window.hcaptcha) {
        settle(() => resolve(window.hcaptcha as HCaptchaApi));
      } else {
        // Defence-in-depth: onload fired but global missing. Treat as
        // a load failure and let the panel show the error UI.
        settle(() => {
          hcaptchaScriptPromise = null;
          reject(new Error('hcaptcha onload fired without window.hcaptcha'));
        });
      }
    };

    if (!existing) {
      const script = document.createElement('script');
      script.src = HCAPTCHA_API_URL;
      script.async = true;
      script.defer = true;
      script.addEventListener('error', () =>
        settle(() => {
          hcaptchaScriptPromise = null;
          reject(new Error('hcaptcha script failed to load'));
        })
      );
      document.head.appendChild(script);
    } else if (window.hcaptcha) {
      // A previous mount already loaded the SDK fully; resolve immediately.
      settle(() => resolve(window.hcaptcha as HCaptchaApi));
      return;
    }
    // Otherwise: existing script is mid-load — our onload hook will fire
    // when it finishes.

    timeoutHandle = window.setTimeout(() => {
      settle(() => {
        hcaptchaScriptPromise = null;
        reject(new Error('hcaptcha script load timed out'));
      });
    }, HCAPTCHA_LOAD_TIMEOUT_MS);
  });
  return hcaptchaScriptPromise;
};

type CaptchaPanelProps = {
  state: {
    kind: 'awaiting_captcha_solve';
    service: string;
    sitekey: string;
    rqdata: string;
    rqtoken: string;
  };
  t: T;
  onSolved: (token: string) => void;
  onCancel: () => void;
  // Fired when the hCaptcha challenge expires (either Discord's
  // server-side rqtoken TTL or our local 90s timer beats the user). The
  // App rolls back to disconnected with a localized warn so the user
  // retries login-qr instead of trying to solve a dead challenge.
  onExpired: () => void;
};

// Match the bridge's CAPTCHA_HYDRATE_FRESHNESS_MS — Discord invalidates
// rqtoken around the 2-minute mark; we expire the UI a little earlier so
// the user retries fresh instead of getting a server-side rejection.
const CAPTCHA_EXPIRY_MS = 90 * 1000;

const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelProps) => {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const widgetIdRef = useRef<string | null>(null);
  const [loadError, setLoadError] = useState<boolean>(false);
  // Set true once we've finished render + setData — gate the solve
  // callback on this so a hCaptcha auto-pass (low-friction users) or a
  // fast click before setData lands can't ship a token unbound to rqdata.
  // Discord rejects unbound tokens with `invalid-response`, burning the
  // rqtoken and forcing a fresh challenge.
  const dataReadyRef = useRef<boolean>(false);
  // Idempotency latch on the solve callback — guards against hCaptcha
  // firing the callback twice (observed when users double-click "I am
  // human" or when low-risk users get the silent auto-pass that some
  // SDK versions deliver via two events). Without this, two
  // login-captcha commands ship to the bridge with the same single-use
  // token and the second one tears down the session via
  // `response-already-used`.
  const solvedRef = useRef<boolean>(false);

  useEffect(() => {
    let cancelled = false;
    dataReadyRef.current = false;
    solvedRef.current = false;
    setLoadError(false);

    // 90-second client-side expiry timer. Drives the localized
    // "captcha_expired" error rather than letting the user solve a
    // dead rqtoken and watch the bridge return a generic Discord
    // failure.
    const expiryTimer = window.setTimeout(() => {
      if (!cancelled && !solvedRef.current) onExpired();
    }, CAPTCHA_EXPIRY_MS);

    // Pass the host theme into hCaptcha so the chrome inside the iframe
    // matches Vojo's surface — the host writes `data-theme` on
    // <html> at boot from the `?theme=` URL param. Without this the
    // hCaptcha widget paints its default light variant inside our
    // Dawn-dark card and reads as a foreign white box.
    const hcaptchaTheme: 'light' | 'dark' =
      document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';

    loadHCaptcha()
      .then((api) => {
        if (cancelled || !containerRef.current) return;
        const id = api.render(containerRef.current, {
          sitekey: state.sitekey,
          theme: hcaptchaTheme,
          size: 'normal',
          callback: (token) => {
            // Gate on dataReadyRef AND solvedRef. dataReadyRef rejects a
            // token before setData bound rqdata; solvedRef rejects
            // duplicate callbacks for the same challenge.
            if (cancelled || !dataReadyRef.current || solvedRef.current) return;
            solvedRef.current = true;
            onSolved(token);
          },
          'expired-callback': () => {
            if (cancelled || solvedRef.current) return;
            onExpired();
          },
          'error-callback': () => {
            if (!cancelled) setLoadError(true);
          },
        });
        // Race guard: cancel may have flipped between the await and
        // render. If so, the iframe is mounted into a detached node — clean
        // up immediately rather than leaking the widget id.
        if (cancelled) {
          try {
            api.remove(id);
          } catch {
            /* ignore */
          }
          return;
        }
        widgetIdRef.current = id;
        // Bind the solve to Discord's challenge nonce. hCaptcha-enterprise
        // (which Discord uses) requires setData AFTER render — passing
        // rqdata into render() is silently ignored.
        try {
          api.setData(id, { rqdata: state.rqdata });
          dataReadyRef.current = true;
        } catch (err) {
          // setData failed (unknown widget id, SDK bug, etc.) — tear the
          // widget down so a stray callback can't ship an unbound token.
          // Surface the load-error UI; the user can retry login-qr.
          try {
            api.remove(id);
          } catch {
            /* ignore */
          }
          widgetIdRef.current = null;
          if (!cancelled) setLoadError(true);
          // eslint-disable-next-line no-console
          console.warn('[captcha] hcaptcha.setData failed', err);
        }
      })
      .catch(() => {
        if (!cancelled) setLoadError(true);
      });
    return () => {
      cancelled = true;
      window.clearTimeout(expiryTimer);
      // Best-effort cleanup. hCaptcha's own remove() is idempotent and
      // tolerates missing widget ids; if the iframe was never rendered
      // (load failure) the catch is harmless.
      try {
        if (window.hcaptcha && widgetIdRef.current) {
          window.hcaptcha.remove(widgetIdRef.current);
        }
      } catch {
        /* ignore */
      }
      widgetIdRef.current = null;
    };
    // rqtoken is the per-challenge nonce — chained captchas reuse the same
    // sitekey/rqdata-shape but bring a new rqtoken; we want a remount on
    // any of these for safety. The whole CaptchaSolveState normally
    // replaces wholesale, but listing all three guards against future
    // partial mutations.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [state.sitekey, state.rqdata, state.rqtoken]);

  return (
    <div class="auth-card auth-card-captcha">
      <div class="auth-card-title">{t('auth-card.captcha.title')}</div>
      <div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
      <div class="auth-card-captcha-frame">
        <div ref={containerRef} class="auth-card-captcha-host" />
        {loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
      </div>
      <div class="auth-card-row">
        <button type="button" class="btn-text" onClick={onCancel}>
          {t('auth-card.cancel')}
        </button>
      </div>
    </div>
  );
};

// --------------------------------------------------------------------------
// About card + modal
// --------------------------------------------------------------------------

type AboutCardProps = {
  t: T;
  onOpen: () => void;
};

const AboutCard = ({ t, onOpen }: AboutCardProps) => (
  <button class="command-card" type="button" onClick={onOpen}>
    <span class="command-card-lead-icon" aria-hidden="true">
      <InfoIcon />
    </span>
    <div class="command-card-body">
      <div class="command-card-name">{t('card.about.name')}</div>
      <div class="command-card-desc">{t('card.about.desc')}</div>
    </div>
    <span class="command-card-chevron" aria-hidden="true">
      ›
    </span>
  </button>
);

type AboutModalProps = {
  t: T;
  onClose: () => void;
};

const AboutModal = ({ t, onClose }: AboutModalProps) => {
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  return (
    <div
      class="about-overlay"
      role="dialog"
      aria-modal="true"
      aria-label={t('about.title')}
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      <div class="about-panel">
        <header class="about-header">
          <h2 class="about-title">{t('about.title')}</h2>
          <button
            type="button"
            class="about-close-x"
            onClick={onClose}
            aria-label={t('about.aria-close')}
          >
            ×
          </button>
        </header>
        <div class="about-body">
          <p>{t('about.body-1')}</p>
          <p>{t('about.body-2')}</p>
          <p>{t('about.body-3')}</p>
          <p>
            {t('about.github-label')}{' '}
            <a href={t('about.github-url')} target="_blank" rel="noreferrer">
              {t('about.github-url')}
            </a>
          </p>
          <p>{t('about.body-4')}</p>
        </div>
        <div class="about-footer">
          <button type="button" class="btn-primary" onClick={onClose}>
            {t('about.close')}
          </button>
        </div>
      </div>
    </div>
  );
};

// --------------------------------------------------------------------------
// Logout card with confirm-in-place
// --------------------------------------------------------------------------

type LogoutCardProps = {
  t: T;
  onConfirm: () => Promise<void>;
};

const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
  const [confirming, setConfirming] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  // Belt-and-suspenders against double-submit. `disabled={submitting}` covers
  // 99% of cases, but there's a microtask window between click and Preact
  // rendering the disabled state where a fast second click could fire.
  const inFlight = useRef(false);

  if (confirming) {
    return (
      <div class="command-card danger">
        <div class="command-card-confirm">
          <span class="command-card-confirm-prompt">{t('card.logout.confirm-prompt')}</span>
          <button
            type="button"
            class="command-card-confirm-yes"
            disabled={submitting}
            onClick={async () => {
              if (inFlight.current) return;
              inFlight.current = true;
              setSubmitting(true);
              try {
                await onConfirm();
              } finally {
                inFlight.current = false;
                setSubmitting(false);
                setConfirming(false);
              }
            }}
          >
            {t('card.logout.confirm-yes')}
          </button>
          <button
            type="button"
            class="command-card-confirm-no"
            disabled={submitting}
            onClick={() => setConfirming(false)}
          >
            {t('card.logout.confirm-no')}
          </button>
        </div>
      </div>
    );
  }

  return (
    <button class="command-card danger" type="button" onClick={() => setConfirming(true)}>
      <span class="command-card-lead-icon" aria-hidden="true">
        <LogoutIcon />
      </span>
      <div class="command-card-body">
        <div class="command-card-name">{t('card.logout.name')}</div>
        <div class="command-card-desc">{t('card.logout.desc')}</div>
      </div>
      <span class="command-card-chevron" aria-hidden="true">
        ›
      </span>
    </button>
  );
};

// --------------------------------------------------------------------------
// Open-space card — Vojo extension
// --------------------------------------------------------------------------

type OpenSpaceCardProps = {
  t: T;
  matrixToUrl: string;
  onOpen: (url: string) => void;
};

// Surfaces the personal Discord space the bridge auto-created at login.
// Renders only when `state.spaceMatrixToUrl` is populated — i.e. against a
// Vojo-patched bridge that emitted `VOJO-LOGIN-SPACE-V1`. Against an
// upstream/unpatched bridge the card is absent (no sentinel, no URL, the
// `space_ready` reducer case never fires).
//
// Click hands the URL to the host via the `io.vojo.bot-widget`
// side-channel (api.openMatrixToUrl) — the widget is sandboxed and
// can't navigate cinny itself. Host validates and routes.
const OpenSpaceCard = ({ t, matrixToUrl, onOpen }: OpenSpaceCardProps) => (
  <button class="command-card" type="button" onClick={() => onOpen(matrixToUrl)}>
    <span class="command-card-lead-icon" aria-hidden="true">
      <SpaceGridIcon />
    </span>
    <div class="command-card-body">
      <div class="command-card-name">{t('card.open-space.name')}</div>
      <div class="command-card-desc">{t('card.open-space.desc')}</div>
    </div>
    <span class="command-card-chevron" aria-hidden="true">
      ›
    </span>
  </button>
);

// --------------------------------------------------------------------------
// Main App
// --------------------------------------------------------------------------

export function App({ bootstrap, api }: Props) {
  const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme);
  const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
  const [handshakeOk, setHandshakeOk] = useState(false);
  const [aboutOpen, setAboutOpen] = useState(false);
  // True while a `ping` probe is in flight from a refresh-card click.
  const [refreshing, setRefreshing] = useState(false);
  const seenEventIds = useRef(new Set<string>());
  const [state, dispatch] = useReducer(loginReducer, initialLoginState);

  // stateRef mirrors latest reducer state so async live-event listeners
  // (attached once at mount) read current state without their stale
  // closure capturing the initial `unknown` snapshot. Used by transcript
  // diag gate for `qr_redacted`.
  const stateRef = useRef(state);
  useEffect(() => {
    stateRef.current = state;
  }, [state]);

  const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]);

  useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  // Capture-phase click interceptor for `<a target="_blank">` —
  // inside Capacitor's Android WebView, cross-origin iframes silently
  // drop those clicks (the WebView has no multi-window concept and
  // the host's setupExternalLinkHandler can't see them across the
  // origin boundary). We preventDefault and ask the host to open the
  // URL via Browser.open / window.open. Modifier-clicks pass through
  // untouched so users can still Ctrl/Cmd-click for new-tab on web.
  useEffect(() => {
    const onClick = (e: MouseEvent) => {
      const anchor = (e.target as HTMLElement | null)?.closest?.(
        'a[target="_blank"]'
      ) as HTMLAnchorElement | null;
      if (!anchor?.href) return;
      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
      e.preventDefault();
      api.openExternalUrl(anchor.href);
    };
    document.addEventListener('click', onClick, true);
    return () => document.removeEventListener('click', onClick, true);
  }, [api]);

  const append = useCallback((line: Omit<TranscriptLine, 'id' | 'ts'>) => {
    setTranscript((prev) => {
      const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }];
      return next.length > TRANSCRIPT_MAX ? next.slice(-TRANSCRIPT_MAX) : next;
    });
  }, []);

  // Newest-at-top — pin scroll to top on each new line.
  const transcriptRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = transcriptRef.current;
    if (!el) return;
    el.scrollTop = 0;
  }, [transcript.length]);

  // Subscribe to widget-api events for capability handshake completion,
  // live events, and theme updates. The `api` itself is constructed in
  // main.tsx BEFORE React's first render so its postMessage listener is
  // already attached — this effect only wires React state to the api's
  // event surface. WidgetApi.on('ready', ...) self-replays if the
  // handshake already finished by the time we attach.
  useEffect(() => {
    let disposed = false;

    api.on('ready', () => {
      setHandshakeOk(true);
      append({ kind: 'diag', text: t('diag.ready') });
      append({ kind: 'diag', text: t('diag.checking-status') });

      void (async () => {
        // Timeline-resume: scan the recent room history BEFORE firing
        // ping. Discord's QR flow doesn't have multi-step prompts (no
        // phone/code/password ladder), but a reload during an active QR
        // scan SHOULD restore the QR panel — otherwise the user reloads,
        // sees disconnected, hits «Войти по QR» again, and the bridge
        // creates a SECOND remoteauth session in parallel with the first
        // (commands.go has no session-deduplication; each call spins a
        // fresh remoteauth.Client goroutine). The hydrate path here is
        // identical in shape to the TG widget's: pull notices, images,
        // and redactions in parallel and feed them chronologically into
        // the hydrate reducer.
        let hydrated = false;
        try {
          const settled = await Promise.allSettled([
            api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.notice' }),
            api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.text' }),
            // QR images: Discord doesn't rotate, so 10 events is plenty
            // (each login attempt produces exactly one m.image). Keep
            // headroom for back-history if the user did multiple
            // attempts in this room over time.
            api.readTimeline({ limit: 10, type: 'm.room.message', msgtype: 'm.image' }),
            api.readTimeline({ limit: 10, type: 'm.room.redaction' }),
          ]);
          if (disposed) return;
          const pickValue = (s: PromiseSettledResult<RoomEvent[]>): RoomEvent[] =>
            s.status === 'fulfilled' ? s.value : [];
          const notices = pickValue(settled[0]);
          const texts = pickValue(settled[1]);
          const qrImages = pickValue(settled[2]);
          const redactions = pickValue(settled[3]);

          const fromBot = (events: RoomEvent[]) =>
            events.filter((e) => e.sender === bootstrap.botMxid);

          // Sort by origin_server_ts ascending, tie-break on event_id.
          // Without the tie-break, equal-timestamp events from different
          // streams could process in nondeterministic order.
          const merged = [
            ...fromBot(notices),
            ...fromBot(texts),
            ...fromBot(qrImages),
            ...fromBot(redactions),
          ].sort((a, b) => {
            const tsDiff = a.origin_server_ts - b.origin_server_ts;
            if (tsDiff !== 0) return tsDiff;
            return a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0;
          });

          const inputs: HydrateInput[] = merged.map((e) => ({
            ev: parseEvent(e),
            ts: e.origin_server_ts,
          }));
          const restored = hydrateFromTimeline(inputs);

          if (restored) {
            // Conservative transcript replay. m.image events are replaced
            // with a generic «QR-код выдан» diag — never replay the raw
            // discord.com/ra/<token> body, that would persist the login
            // token in DOM history past the bridge's redaction. Bot
            // notices replay verbatim (they're already redacted of
            // sensitive data by the bridge).
            let appendedAnyHistory = false;
            const seenQrIds = new Set<string>();
            for (const e of merged) {
              if (seenEventIds.current.has(e.event_id)) continue;
              seenEventIds.current.add(e.event_id);
              const parsed = parseEvent(e);
              if (parsed.kind === 'qr_displayed') {
                seenQrIds.add(parsed.eventId);
                if (parsed.replacesEventId) seenQrIds.add(parsed.replacesEventId);
                append({ kind: 'diag', text: t('diag.qr-issued') });
                appendedAnyHistory = true;
              } else if (parsed.kind === 'qr_redacted') {
                if (seenQrIds.has(parsed.redactsEventId)) {
                  append({ kind: 'diag', text: t('diag.qr-consumed') });
                  appendedAnyHistory = true;
                }
              } else if (parsed.kind === 'captcha_challenge') {
                // Captcha challenge body carries hCaptcha rqdata/rqtoken
                // — short-lived but credential-bearing. Diag-only on
                // hydrate too; the live path treats it identically.
                append({ kind: 'diag', text: t('diag.captcha-issued') });
                appendedAnyHistory = true;
              } else if (parsed.kind === 'space_ready') {
                // VOJO-LOGIN-SPACE-V1 sentinel body is a JSON blob —
                // machine-readable, never user-readable. Suppress the raw
                // body from the transcript and emit a diag breadcrumb
                // instead so a reload-replay shows «space ready» rather
                // than `VOJO-LOGIN-SPACE-V1 {"matrix_to_url":"…"}` ugly
                // verbatim. Same discipline as the captcha branch above.
                append({ kind: 'diag', text: t('diag.space-ready') });
                appendedAnyHistory = true;
              } else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
                // m.text / m.notice — body is safe to replay verbatim,
                // BUT we still scrub any login-URL-shaped substring as
                // belt-and-suspenders against future bridge wording
                // drift that could echo the URL through a notice.
                append({
                  kind: 'from-bot',
                  text: `← ${scrubLoginSecret(e.content.body ?? '')}`,
                });
                appendedAnyHistory = true;
              }
            }
            if (appendedAnyHistory) {
              append({ kind: 'diag', text: t('diag.history-marker') });
            }

            dispatch({ kind: 'hydrate', state: restored });
            hydrated = true;
          }
        } catch {
          if (!disposed) {
            append({ kind: 'diag', text: t('diag.history-unavailable') });
          }
        }

        if (disposed) return;
        if (!hydrated) {
          // Discord's status probe is `ping`, not `list-logins`. The reply
          // routes through the reducer to disconnected / connected /
          // connected_dead.
          api.sendCommand('ping').catch((err) => {
            if (disposed) return;
            append({
              kind: 'error',
              text: t('diag.send-failed', { message: (err as Error).message }),
            });
          });
        }
      })();
    });

    api.on('themeChange', (name) => setTheme(name));

    api.on('liveEvent', (ev: RoomEvent) => {
      if (seenEventIds.current.has(ev.event_id)) return;
      seenEventIds.current.add(ev.event_id);
      // Defense-in-depth sender filter — the host's strict 1:1 invariant
      // already guarantees this, but pinning to bootstrap.botMxid prevents
      // (a) skipping our own outbound echoes (already appended optimistically),
      // (b) third-party noise that somehow slips past the 1:1 invariant.
      if (ev.sender !== bootstrap.botMxid) return;

      const event = parseEvent(ev);

      // Transcript routing is GATED on the parser's verdict, not raw event
      // type. Same logic as TG widget: m.image bodies are NEVER appended
      // verbatim (they ARE the login secret); QR-redaction diag fires only
      // for the active QR.
      if (event.kind === 'qr_displayed') {
        append({ kind: 'diag', text: t('diag.qr-issued') });
      } else if (event.kind === 'qr_redacted') {
        const liveState = stateRef.current;
        if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
          append({ kind: 'diag', text: t('diag.qr-consumed') });
        }
      } else if (event.kind === 'captcha_challenge') {
        // The raw body carries Discord's hCaptcha sitekey + rqdata + rqtoken
        // — short-lived but credential-bearing. Don't echo into the
        // transcript DOM (where screenshots / accessibility tools could
        // leak them). Diag-only display.
        append({ kind: 'diag', text: t('diag.captcha-issued') });
      } else if (event.kind === 'space_ready') {
        // Sentinel body is the JSON `{"matrix_to_url":"…"}` — not human-
        // readable and pointless to show verbatim. Emit a diag breadcrumb;
        // the actual «Open in Channels» card is rendered by the reducer
        // attaching `spaceMatrixToUrl` to the connected state.
        append({ kind: 'diag', text: t('diag.space-ready') });
      } else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
        const body = ev.content.body ?? '';
        append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` });
      }

      dispatch({ kind: 'event', event });

      // Fire `ping` after lifecycle transitions that need authoritative
      // state reconciliation:
      //   * login_success — the success line lacks the discordId; ping
      //     picks it up so the connected pill can show the snowflake.
      //   * reconnect_ok / reconnect_no_op — flips us back into connected
      //     but with potentially-stale handle; ping refreshes.
      //   * already_logged_in — bridge says we tried login while already
      //     in. Without a re-ping the QR-form stays open with a warn
      //     banner forever (no QR will ever come because the bridge
      //     bails before remoteauth.New). Re-pinging routes us to the
      //     connected pill so the user can click logout if they wanted
      //     a fresh login.
      if (
        event.kind === 'login_success' ||
        event.kind === 'reconnect_ok' ||
        event.kind === 'reconnect_no_op' ||
        event.kind === 'already_logged_in'
      ) {
        api.sendCommand('ping').catch(() => {
          /* surface in diag is overkill; the connected pill still shows
             the handle even without the snowflake */
        });
      }
    });

    append({ kind: 'diag', text: t('diag.connecting') });

    return () => {
      disposed = true;
      api.dispose();
    };
    // `api`, `bootstrap`, `t`, and `append` are stable for the App's lifetime.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Outbound bare-command + transcript echo. Errors append to transcript
  // AND rethrow — callers decide whether to roll back optimistic transitions.
  // `api` is a stable singleton owned by main.tsx; closing over it directly
  // is safe (the App's lifetime is the iframe's, and api.dispose() in the
  // unmount cleanup makes any in-flight sends fail loudly).
  const sendBare = useCallback(
    async (command: string): Promise<void> => {
      append({ kind: 'from-user', text: `→ ${command}` });
      try {
        await api.sendCommand(command);
      } catch (err) {
        append({
          kind: 'error',
          text: t('diag.send-failed', { message: (err as Error).message }),
        });
        throw err;
      }
    },
    [append, api, t]
  );

  // Specialised send for hCaptcha tokens. The token is a single-use
  // credential — we send it to the bridge but redact the body in the
  // user-visible transcript so it doesn't sit in DOM (and screenshots /
  // accessibility tools can't see it). The error path keeps the same
  // shape as `sendBare` so the caller can `try { await … } catch { roll back }`.
  const sendCaptchaToken = useCallback(
    async (token: string): Promise<void> => {
      append({ kind: 'from-user', text: '→ login-captcha [redacted]' });
      try {
        await api.sendCommand(`login-captcha ${token}`);
      } catch (err) {
        append({
          kind: 'error',
          text: t('diag.send-failed', { message: (err as Error).message }),
        });
        throw err;
      }
    },
    [append, api, t]
  );

  // In-flight guard against double-tap. The button is on the disconnected
  // screen which unmounts as soon as state advances, BUT a rapid second
  // click can fire in the microtask window between dispatch and the next
  // Preact commit (especially on Android WebView, where a tap-rebound can
  // synthesise a second click). For login-qr, a duplicate would spin a
  // SECOND remoteauth goroutine on the bridge in parallel — harmless but
  // wastes a remoteauth session.
  const loginInFlight = useRef(false);

  const onClickLoginQr = useCallback(async () => {
    if (loginInFlight.current) return;
    loginInFlight.current = true;
    dispatch({ kind: 'start_qr_login' });
    try {
      await sendBare('login-qr');
    } catch {
      dispatch({ kind: 'cancel_pending' });
    } finally {
      loginInFlight.current = false;
    }
  }, [sendBare]);

  // Cancel is LOCAL — Discord legacy mautrix has no `cancel` command.
  // Returns the widget to disconnected; the bridge's remoteauth goroutine
  // continues until success / failure / internal timeout.
  const onClickCancel = useCallback(() => {
    dispatch({ kind: 'cancel_pending' });
  }, []);

  // Captcha solve callback — fires from hCaptcha's own iframe. We dispatch
  // `submit_captcha_token` first (transition to qr_verifying immediately so
  // the user sees a spinner), then send `login-captcha <token>` to the
  // bridge. The bridge re-fires Discord's RemoteAuthLogin with the
  // X-Captcha-Key header attached and replies via the live event stream.
  const onCaptchaSolved = useCallback(
    async (token: string) => {
      dispatch({ kind: 'submit_captcha_token' });
      try {
        await sendCaptchaToken(token);
      } catch {
        // Couldn't deliver to the bridge — roll the optimistic
        // qr_verifying back to disconnected with a localized error.
        // Don't auto-fall-through to ping: the failure is local
        // (transport), not a bridge state mismatch, and a stale ping
        // would mask it.
        dispatch({ kind: 'captcha_send_failed' });
      }
    },
    [sendCaptchaToken]
  );

  // hCaptcha challenge expired (server-side rqtoken TTL or 90s local
  // timer). Tell the user with a localized warn rather than letting them
  // solve a dead challenge.
  const onCaptchaExpired = useCallback(() => {
    dispatch({ kind: 'captcha_expired' });
  }, []);

  const onClickRefresh = useCallback(async () => {
    if (refreshing) return;
    setRefreshing(true);
    const start = Date.now();
    try {
      await sendBare('ping');
    } catch {
      /* transcript carries the failure */
    }
    // 500 ms minimum visible loading state — without this, a fast healthy
    // transport (<100ms round-trip) skips a paint frame entirely and the
    // click goes visually unacknowledged.
    const elapsed = Date.now() - start;
    if (elapsed < 500) {
      await new Promise<void>((resolve) => {
        window.setTimeout(resolve, 500 - elapsed);
      });
    }
    setRefreshing(false);
  }, [refreshing, sendBare]);

  const onConfirmLogout = useCallback(async () => {
    dispatch({ kind: 'request_logout' });
    try {
      await sendBare('logout');
    } catch {
      // Recovery: refire ping so the reducer recalibrates from bridge truth
      // instead of leaving the UI stuck in logging_out forever.
      sendBare('ping').catch(() => {
        /* user can hit refresh */
      });
    }
  }, [sendBare]);

  const onClickReconnect = useCallback(async () => {
    // Carry the current handle through `reconnecting` so the post-reconnect
    // success path can flip directly to `connected{handle}` without
    // bouncing through `unknown`. The handle is read from whichever
    // pre-reconnect state we're in (connected_dead is the typical
    // entry, but a manual disconnect path could leave us in connected
    // and trigger reconnect from there).
    const handle =
      state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined;
    dispatch({ kind: 'request_reconnect', handle });
    try {
      await sendBare('reconnect');
    } catch {
      sendBare('ping').catch(() => {
        /* user can hit refresh */
      });
    }
  }, [sendBare, state]);

  // Convenience: render a status pill with optional recovery button.
  type StatusRowProps = {
    tone: 'connected' | 'disconnected' | 'checking';
    label: string;
    recovery?: { label: string; icon?: ComponentChildren; onClick: () => void; disabled?: boolean };
  };
  const StatusRow = ({ tone, label, recovery }: StatusRowProps) => {
    const pill = (
      <span class={`section-status ${tone}`} role="status">
        <span class="dot" />
        {label}
      </span>
    );
    if (!recovery) return pill;
    return (
      <div class="section-recovery-row">
        {pill}
        <button
          type="button"
          class="recovery-action"
          onClick={recovery.onClick}
          disabled={recovery.disabled}
        >
          {recovery.icon ?? <RefreshIcon />}
          {recovery.label}
        </button>
      </div>
    );
  };

  return (
    <div class="app">
      {/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no
       * longer renders an avatar/name/handle/description block — the host
       * panel above the iframe carries that information plus the
       * three-dots menu. «О боте» lives HERE in the widget body so it
       * sits adjacent to the login/logout actions it explains. */}

      {handshakeOk && state.kind === 'unknown' ? (
        <section class="section">
          <StatusRow
            tone="checking"
            label={t('status.unknown')}
            recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
          />
        </section>
      ) : null}

      {handshakeOk && state.kind === 'disconnected' ? (
        <section class="section">
          <StatusRow tone="disconnected" label={t('status.disconnected')} />
          {state.lastError ? (
            <div
              class={errorTone(state.lastError) === 'warn' ? 'auth-card-warn' : 'auth-card-error'}
              style={{ marginBottom: '14px' }}
            >
              {localizeError(state.lastError, t)}
            </div>
          ) : null}
          <div class="command-grid">
            <button class="command-card" type="button" onClick={onClickLoginQr}>
              <span class="command-card-lead-icon" aria-hidden="true">
                <QrIcon />
              </span>
              <div class="command-card-body">
                <div class="command-card-name">{t('card.login-qr.name')}</div>
                <div class="command-card-desc">{t('card.login-qr.desc')}</div>
              </div>
              <span class="command-card-chevron" aria-hidden="true">
                ›
              </span>
            </button>
            <button
              class={`command-card${refreshing ? ' refreshing' : ''}`}
              type="button"
              onClick={onClickRefresh}
              disabled={refreshing}
            >
              <span class="command-card-lead-icon" aria-hidden="true">
                <RefreshIcon />
              </span>
              <div class="command-card-body">
                <div class="command-card-name">{t('card.refresh.name')}</div>
                <div class="command-card-desc">
                  {refreshing ? t('card.refresh.in-flight') : t('card.refresh.desc')}
                </div>
              </div>
              <span class="command-card-chevron" aria-hidden="true">
                ›
              </span>
            </button>
            <AboutCard t={t} onOpen={() => setAboutOpen(true)} />
          </div>
        </section>
      ) : null}

      {state.kind === 'awaiting_qr_scan' ? (
        <section class="section">
          <QrPanel state={state} t={t} onCancel={onClickCancel} />
        </section>
      ) : null}

      {state.kind === 'awaiting_captcha_solve' ? (
        <section class="section">
          <CaptchaPanel
            state={state}
            t={t}
            onSolved={onCaptchaSolved}
            onCancel={onClickCancel}
            onExpired={onCaptchaExpired}
          />
        </section>
      ) : null}

      {state.kind === 'qr_verifying' ? (
        <section class="section">
          <StatusRow
            tone="checking"
            label={t('status.qr-verifying')}
            recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
          />
        </section>
      ) : null}

      {state.kind === 'logging_out' ? (
        <section class="section">
          <StatusRow
            tone="checking"
            label={t('status.logging-out')}
            recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
          />
        </section>
      ) : null}

      {state.kind === 'reconnecting' ? (
        <section class="section">
          <StatusRow
            tone="checking"
            label={t('status.reconnecting')}
            recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
          />
        </section>
      ) : null}

      {state.kind === 'connected' ? (
        <section class="section">
          <StatusRow
            tone="connected"
            label={
              state.handle
                ? t('status.connected-as', { handle: state.handle })
                : t('status.connected')
            }
          />
          <div class="command-grid">
            {/* Open-space CTA — only against a Vojo-patched bridge that
             * emitted the VOJO-LOGIN-SPACE-V1 sentinel. Listed first so a
             * fresh post-login user sees «next step» before the Logout
             * destructive action. */}
            {state.spaceMatrixToUrl ? (
              <OpenSpaceCard
                t={t}
                matrixToUrl={state.spaceMatrixToUrl}
                onOpen={(url) => api.openMatrixToUrl(url)}
              />
            ) : null}
            <LogoutCard t={t} onConfirm={onConfirmLogout} />
            <AboutCard t={t} onOpen={() => setAboutOpen(true)} />
          </div>
        </section>
      ) : null}

      {state.kind === 'connected_dead' ? (
        <section class="section">
          <StatusRow
            tone="checking"
            label={
              state.reason === 'connection_dead'
                ? t('status.connection-dead')
                : t('status.token-stored')
            }
          />
          <div class="command-grid">
            {/* Reconnect — primary action for this state. The button uses
             * the same command-card chrome so it visually matches Login /
             * Logout cards. */}
            <button class="command-card" type="button" onClick={onClickReconnect}>
              <span class="command-card-lead-icon" aria-hidden="true">
                <LinkIcon />
              </span>
              <div class="command-card-body">
                <div class="command-card-name">{t('card.reconnect.name')}</div>
                <div class="command-card-desc">{t('card.reconnect.desc')}</div>
              </div>
              <span class="command-card-chevron" aria-hidden="true">
                ›
              </span>
            </button>
            <LogoutCard t={t} onConfirm={onConfirmLogout} />
            <AboutCard t={t} onOpen={() => setAboutOpen(true)} />
          </div>
        </section>
      ) : null}

      {aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(false)} /> : null}

      <section class="section">
        <div ref={transcriptRef} class="transcript" role="log" aria-live="polite">
          {transcript.length === 0 ? (
            <div class="transcript-empty">{/* placeholder */}</div>
          ) : (
            transcript
              .slice()
              .reverse()
              .map((line) => (
                <div key={line.id} class={`transcript-line ${line.kind}`}>
                  <span class="ts">{formatTime(line.ts)}</span>
                  <span class="body">
                    {line.kind === 'from-bot' ? renderBody(line.text) : line.text}
                  </span>
                </div>
              ))
          )}
        </div>
      </section>
    </div>
  );
}
