import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
import type { Dispatch } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import type { WidgetBootstrap } from './bootstrap';
import { WidgetApi, type RoomEvent } from './widget-api';
import { createT, type T } from './i18n';
import { parseReply } from './bridge-protocol/parser';
import {
  initialLoginState,
  loginReducer,
  type LoginAction,
  type LoginErrorFlag,
  type LoginState,
} from './state';

// Visual canon: «Боты · Commands IDE» mockup at
// docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
// function BotsDesktop (lines 651-707) — Dawn palette, fleet-violet accent,
// 56px square avatar, monospace handles. M12 adds three inline form cards
// (.auth-card) and a destructive logout card (.command-card.danger) inside
// the same vocabulary.

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, so its message listener is attached before the host's
  // ClientWidgetApi sends its capabilities request on iframe `load`.
  // Constructing inside a useEffect here would race with the cached-bundle
  // case (second mount after «Show chat» → «Show widget») and silently
  // miss the handshake. Keep the construction at module-load.
  api: WidgetApi;
};

const TRANSCRIPT_MAX = 200;

// 8s — see plan: hint window between submit and bot reply. SMS-side latency
// is owned by the user (they can see the form is open and waiting).
const STILL_WAITING_DELAY_MS = 8_000;

// 30s countdown shown on the code form. The phone-form hint warns about a
// 30-second SMS window; this is the visible counterpart on the form that
// actually waits for the SMS to arrive.
const CODE_COUNTDOWN_SECONDS = 30;

// Inline SVG refresh icon — replaces the unicode «⟳» glyph that read more
// like ASCII art than UI in the previous draft. Stroke-only, so it picks
// up `currentColor` from the parent button and matches the Dawn palette
// without a colored asset.
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>
);

// Linkifier: matches plain http/https URLs in transcript bodies. Bot replies
// regularly carry matrix.to URLs (success line includes one), and our M11
// scaffold previously rendered them as inert text.
const URL_RE = /https?:\/\/[^\s)]+/g;

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}`;
};

// Build transcript children from a body string with naive URL linkification.
// We render in plaintext-with-anchors mode only; markdown formatting from the
// bot (backticks, asterisks) shows literal — it's not load-bearing for the
// authentication flow and rendering raw is safer than reimplementing markdown.
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;
};

// Mask outbound secrets before they hit the local transcript. The widget
// never *sends* the masked form — `WidgetApi.sendCommand` always carries
// the real value over the wire. Server-side redaction handles password
// and token; the 2FA phone code is NOT redacted upstream (bridgev2 only
// redacts Password/Token field types — see plan «Architectural facts»
// note 5), so the privacy hint shown after success matters.
const redactOutbound = (body: string, kind: 'phone' | 'code' | 'password'): string => {
  if (kind === 'phone') return body;
  if (kind === 'code') return '••••••';
  return '••••••••';
};

const localizeError = (err: LoginErrorFlag, t: T): string => {
  switch (err.kind) {
    case 'invalid_code':
      return t('auth-error.invalid-code');
    case 'wrong_password':
      return t('auth-error.wrong-password');
    case 'invalid_value':
      return t('auth-error.invalid-value', { reason: err.reason ?? '' });
    case 'submit_failed':
      return t('auth-error.submit-failed', { reason: err.reason ?? '' });
    case 'login_in_progress':
      return t('auth-error.login-in-progress');
    case 'max_logins':
      return t('auth-error.max-logins', { limit: String(err.limit ?? '?') });
    case 'unknown_command':
      return t('auth-error.unknown-command');
    case 'start_failed':
      return t('auth-error.start-failed', { reason: err.reason ?? '' });
    case 'prepare_failed':
      return t('auth-error.prepare-failed', { reason: err.reason ?? '' });
    default: {
      const exhaustive: never = err;
      return String(exhaustive);
    }
  }
};

// Centralised affordance: does the current error get rendered as red
// .auth-card-error or yellow .auth-card-warn? Telegram-side soft errors
// (FloodWait, banned, unregistered — all funneled through submit_failed)
// are warnings. Wrong-input errors are red.
const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
  if (err.kind === 'submit_failed') return 'warn';
  if (err.kind === 'login_in_progress') return 'warn';
  return 'error';
};

// --------------------------------------------------------------------------
// Form components
// --------------------------------------------------------------------------

type FormProps = {
  state: LoginState;
  t: T;
  dispatch: Dispatch<LoginAction>;
  send: (body: string, mask: 'phone' | 'code' | 'password') => Promise<void>;
  sendCancel: () => Promise<void>;
  // Phone-form cooldown plumbing — lifted to App so it survives the form's
  // unmount on Cancel + remount on a fresh «Войти по номеру» click. Without
  // this, a user could spam Send Code → Cancel → repeat and fire SMS at
  // Telegram's rate-limit ceiling before bridgev2 even sees a flood.
  phoneCooldownEnd: number | null;
  setPhoneCooldownEnd: (ts: number | null) => void;
};

// Phone-submit cooldown — Telegram throttles repeat SMS hard, and a
// half-second of UI lag on submit makes spamming the button trivial.
// 60 s matches Telegram Desktop's own "Resend code" lockout.
const PHONE_COOLDOWN_MS = 60_000;

// Tick once per second while a future timestamp is still in the future.
// Returns the seconds remaining (0 once expired). When `until` is null
// the hook is idle.
const useCooldownSeconds = (until: number | null): number => {
  const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0);
  const [seconds, setSeconds] = useState(compute);
  useEffect(() => {
    if (!until) {
      setSeconds(0);
      return undefined;
    }
    setSeconds(compute());
    const timer = window.setInterval(() => {
      const next = Math.max(0, Math.ceil((until - Date.now()) / 1000));
      setSeconds(next);
      if (next <= 0) window.clearInterval(timer);
    }, 1000);
    return () => window.clearInterval(timer);
    // `compute` is referentially fresh each render but captures `until`;
    // depending on it would re-run on every render. The effect only needs
    // to re-run when `until` itself changes.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [until]);
  return seconds;
};

const useStillWaitingHint = (deps: ReadonlyArray<unknown>): boolean => {
  const [show, setShow] = useState(false);
  useEffect(() => {
    setShow(false);
    const timer = window.setTimeout(() => setShow(true), STILL_WAITING_DELAY_MS);
    return () => window.clearTimeout(timer);
    // The deps array is intentionally controlled by the caller — restart
    // the timer on every meaningful state change (e.g. submit).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);
  return show;
};

const PhoneForm = ({
  state,
  t,
  dispatch,
  send,
  sendCancel,
  phoneCooldownEnd,
  setPhoneCooldownEnd,
}: FormProps) => {
  const [value, setValue] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const stillWaiting = useStillWaitingHint([submitting]);
  const cooldownSeconds = useCooldownSeconds(phoneCooldownEnd);
  const inCooldown = cooldownSeconds > 0;
  const error = state.kind === 'awaiting_phone' ? state.lastError : undefined;

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  const onSubmit = async (event: Event) => {
    event.preventDefault();
    const trimmed = value.trim();
    if (!trimmed || submitting || inCooldown) return;
    setSubmitting(true);
    // Clear any stale error optimistically so the form looks ready for the
    // next attempt; a fresh error will re-arrive from the bot if the
    // submit fails server-side.
    dispatch({ kind: 'submit_phone' });
    try {
      await send(trimmed, 'phone');
      // Cooldown locks retries ONLY after the Matrix transport accepted
      // the message. If `await send` threw (network down, capability
      // race, etc.), no SMS was attempted at the Telegram side — locking
      // the form for 60s would punish the user for an issue they can fix
      // by clicking again. The cooldown is also cleared by the App when
      // the bot replies with `invalid_value` (malformed phone format —
      // bridgev2 rejects before SMS dispatch); see App-level effect.
      setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS);
    } catch {
      /* transcript carries the diagnostic; form stays open for retry */
    } finally {
      setSubmitting(false);
    }
  };

  const tone = error ? errorTone(error) : undefined;
  const submitDisabled = submitting || inCooldown || value.trim() === '';
  const submitLabel = inCooldown
    ? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
    : t('auth-card.phone.submit');

  return (
    <form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
      <div class="auth-card-title">{t('auth-card.phone.title')}</div>
      <label class="auth-card-hint" for="auth-phone-input">
        {t('auth-card.phone.label')}
      </label>
      <div class="auth-card-row">
        <input
          id="auth-phone-input"
          ref={inputRef}
          class="auth-input"
          type="tel"
          autocomplete="tel"
          inputmode="tel"
          placeholder={t('auth-card.phone.placeholder')}
          value={value}
          onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
          disabled={submitting}
        />
        <button type="submit" class="btn-primary" disabled={submitDisabled}>
          {submitLabel}
        </button>
        <button type="button" class="btn-text" onClick={sendCancel}>
          {t('auth-card.cancel')}
        </button>
      </div>
      <div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
      {error ? (
        <div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
          {localizeError(error, t)}
        </div>
      ) : null}
      {submitting && stillWaiting ? (
        <div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
      ) : null}
    </form>
  );
};

// Tick-down hook for the SMS countdown. Starts on mount, decrements every
// second until 0, holds. Returns a single number; the caller decides what
// to render at 0 (we flip to a «didn't arrive — try again» hint).
const useCountdown = (initialSeconds: number): number => {
  const [remaining, setRemaining] = useState(initialSeconds);
  useEffect(() => {
    if (remaining <= 0) return undefined;
    const timer = window.setTimeout(() => setRemaining((s) => Math.max(0, s - 1)), 1000);
    return () => window.clearTimeout(timer);
  }, [remaining]);
  return remaining;
};

const CodeForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => {
  const [value, setValue] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const stillWaiting = useStillWaitingHint([submitting]);
  // SMS countdown ticks from mount of the code form (i.e. when the bot
  // confirmed it sent a code). Independent of `submitting` — the timer
  // is about SMS arrival, not bot-reply latency.
  const countdownSeconds = useCountdown(CODE_COUNTDOWN_SECONDS);
  const error = state.kind === 'awaiting_code' ? state.lastError : undefined;

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  const onSubmit = async (event: Event) => {
    event.preventDefault();
    const trimmed = value.trim();
    if (!trimmed || submitting) return;
    setSubmitting(true);
    // Clear input value AND stale error optimistically — user is making a
    // fresh attempt; the old red-state would conflict visually with the new
    // transcript-side outcome (which may itself be a different error).
    setValue('');
    dispatch({ kind: 'submit_code' });
    try {
      await send(trimmed, 'code');
    } catch {
      /* transcript carries the diagnostic; form stays open for retry */
    } finally {
      setSubmitting(false);
    }
  };

  const tone = error ? errorTone(error) : undefined;

  return (
    <form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
      <div class="auth-card-title">{t('auth-card.code.title')}</div>
      <label class="auth-card-hint" for="auth-code-input">
        {t('auth-card.code.label')}
      </label>
      <div class="auth-card-row">
        <input
          id="auth-code-input"
          ref={inputRef}
          class="auth-input code"
          type="text"
          autocomplete="one-time-code"
          inputmode="numeric"
          maxLength={6}
          placeholder={t('auth-card.code.placeholder')}
          value={value}
          onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
          disabled={submitting}
        />
        <button type="submit" class="btn-primary" disabled={submitting || value.trim() === ''}>
          {t('auth-card.code.submit')}
        </button>
        <button type="button" class="btn-text" onClick={sendCancel}>
          {t('auth-card.cancel')}
        </button>
      </div>
      {error ? (
        <div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
          {localizeError(error, t)}
        </div>
      ) : null}
      {/*
       * SMS countdown is suppressed while the form is submitting — the
       * bot-latency hint takes over once the user has typed and clicked.
       * Both rendering simultaneously stacks two distinct waits (SMS
       * arrival vs bot reply) on the same form and reads as clutter.
       */}
      {!submitting &&
        (countdownSeconds > 0 ? (
          <div class="auth-card-countdown">
            {t('auth-card.code.countdown', { seconds: String(countdownSeconds) })}
          </div>
        ) : (
          <div class="auth-card-countdown expired">{t('auth-card.code.countdown-done')}</div>
        ))}
      {submitting && stillWaiting ? (
        <div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
      ) : null}
    </form>
  );
};

const PasswordForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => {
  const [value, setValue] = useState('');
  const [reveal, setReveal] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const stillWaiting = useStillWaitingHint([submitting]);
  const error = state.kind === 'awaiting_password' ? state.lastError : undefined;

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  const onSubmit = async (event: Event) => {
    event.preventDefault();
    if (!value || submitting) return;
    setSubmitting(true);
    // Capture the plaintext into a local and drop it from component state
    // BEFORE awaiting the send. If the send throws or the form unmounts
    // mid-flight, no plaintext lingers in React state or the DOM input —
    // server-side redaction (bridgev2/commands/login.go:226) only fires
    // after the message arrives, and we don't want a window where the
    // password sits accessible to Preact Devtools / source maps.
    const password = value;
    setValue('');
    dispatch({ kind: 'submit_password' });
    try {
      await send(password, 'password');
    } catch {
      /* transcript carries the diagnostic; user retypes */
    } finally {
      setSubmitting(false);
    }
  };

  const tone = error ? errorTone(error) : undefined;

  return (
    <form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
      <div class="auth-card-title">{t('auth-card.password.title')}</div>
      <div class="auth-card-hint">{t('auth-card.password.hint')}</div>
      <label class="auth-card-hint" for="auth-password-input">
        {t('auth-card.password.label')}
      </label>
      <div class="auth-card-row">
        <div class="password-row">
          <input
            id="auth-password-input"
            ref={inputRef}
            class="auth-input password"
            type={reveal ? 'text' : 'password'}
            autocomplete="current-password"
            value={value}
            onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
            disabled={submitting}
          />
          <button
            type="button"
            class="btn-icon"
            onClick={() => setReveal((v) => !v)}
            aria-label={reveal ? t('auth-card.password.hide') : t('auth-card.password.show')}
          >
            {reveal ? t('auth-card.password.hide') : t('auth-card.password.show')}
          </button>
        </div>
        <button type="submit" class="btn-primary" disabled={submitting || value === ''}>
          {t('auth-card.password.submit')}
        </button>
        <button type="button" class="btn-text" onClick={sendCancel}>
          {t('auth-card.cancel')}
        </button>
      </div>
      {error ? (
        <div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
          {localizeError(error, t)}
        </div>
      ) : null}
      {submitting && stillWaiting ? (
        <div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
      ) : null}
    </form>
  );
};

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

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

const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => {
  const [confirming, setConfirming] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  // Belt-and-suspenders against double-submit. `disabled={submitting}` on the
  // button covers 99% of cases, but there's a microtask window between click
  // and React/Preact rendering the disabled state where a fast second click
  // could fire — the ref closes that window synchronously.
  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={!loginId || submitting}
            onClick={async () => {
              if (!loginId || inFlight.current) return;
              inFlight.current = true;
              setSubmitting(true);
              try {
                await onConfirm(loginId);
              } 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)}
      disabled={!loginId}
      title={!loginId ? t('card.logout.gated') : undefined}
    >
      <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>
  );
};

// --------------------------------------------------------------------------
// 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 apiRef = useRef<WidgetApi | null>(api);
  const seenEventIds = useRef(new Set<string>());
  const [state, dispatch] = useReducer(loginReducer, initialLoginState);

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

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

  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;
    });
  }, []);

  // Pin the transcript to the bottom whenever a new line lands. Bot replies
  // are append-only so this is the right default; users who scroll up will
  // be snapped back on the next message — acceptable here because the
  // transcript is a passive log, not a primary reading surface (forms and
  // status pills are above it).
  const transcriptRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = transcriptRef.current;
    if (!el) return;
    el.scrollTop = el.scrollHeight;
  }, [transcript.length]);

  // App-level cooldown state for phone-form Send Code button. Lifted out of
  // PhoneForm so it survives the form's unmount on Cancel + remount on a
  // fresh login click — otherwise the user could spam Send→Cancel→repeat
  // and saturate Telegram's SMS rate limit before bridgev2 reports back.
  const [phoneCooldownEnd, setPhoneCooldownEnd] = useState<number | null>(null);

  // Clear the cooldown when bridge replies that our phone was malformed —
  // `invalid_value` on the awaiting_phone form means the value was
  // rejected at validate-time, BEFORE bridgev2 dispatched any Telegram
  // API call. No SMS was attempted, so the 60s lock would just punish a
  // typo. The cooldown stays in place for `submit_failed` (Telegram-side
  // FloodWait/banned/etc — those cases SMS may have been attempted).
  useEffect(() => {
    if (
      state.kind === 'awaiting_phone' &&
      state.lastError?.kind === 'invalid_value' &&
      phoneCooldownEnd !== null
    ) {
      setPhoneCooldownEnd(null);
    }
  }, [state, phoneCooldownEnd]);

  // 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 (cached-bundle
  // remount: bundle parses near-instantly and the host's capabilities
  // request can resolve before this useEffect runs).
  useEffect(() => {
    api.on('ready', () => {
      setHandshakeOk(true);
      append({ kind: 'diag', text: t('diag.ready') });
      append({ kind: 'diag', text: t('diag.checking-status') });
      api.sendCommand('list-logins').catch((err) => {
        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. Phase 1's strict 1:1 invariant
      // already guarantees the only senders in this DM are the user and
      // the bot, but pinning to bootstrap.botMxid covers both:
      // (a) skip our own outbound echoes (those are appended
      //     optimistically with masking applied at click time — the live
      //     event would render the unmasked password back into the
      //     transcript), and
      // (b) ignore any third-party noise that somehow slips past the
      //     1:1 invariant.
      if (ev.sender !== bootstrap.botMxid) return;
      const body = ev.content.body ?? '';
      append({ kind: 'from-bot', text: `← ${body}` });

      // Bot reply → LoginEvent → state machine. Ignore msgtype-specific
      // routing — bridgev2 sends every login reply as m.notice; the host
      // driver already filters to m.text/m.notice on the receive path.
      const event = parseReply(body);
      dispatch({ kind: 'event', event });

      // After a fresh login_success the bridge doesn't send the loginId in
      // the success line. Re-run list-logins so the reducer's `connected`
      // state can pick up the loginId for the future logout call.
      if (event.kind === 'login_success') {
        api.sendCommand('list-logins').catch(() => {
          /* surface in diag is overkill; the connected hero still works
             without a loginId until the user clicks logout */
        });
      }
    });

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

    return () => {
      // App-level unmount tears down the iframe window entirely (host
      // detaches the iframe DOM node), so dispose just clears pending
      // request promises. Don't null `apiRef.current` — `api` is a
      // module-level singleton owned by main.tsx, not by this component.
      api.dispose();
    };
    // `api`, `bootstrap`, `t`, and `append` are stable for the App's
    // lifetime; the effect intentionally runs once at mount.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Outbound command + transcript echo. `mask` decides what shows up in the
  // local transcript; the wire payload always carries the real value.
  // Errors are appended to the transcript AND rethrown — callers decide
  // whether to roll back optimistic state transitions.
  const send = useCallback(
    async (body: string, mask: 'phone' | 'code' | 'password' = 'phone'): Promise<void> => {
      const api = apiRef.current;
      if (!api) throw new Error('widget transport not ready');
      append({ kind: 'from-user', text: `→ ${redactOutbound(body, mask)}` });
      try {
        await api.sendCommand(body);
      } catch (err) {
        append({
          kind: 'error',
          text: t('diag.send-failed', { message: (err as Error).message }),
        });
        throw err;
      }
    },
    [append, t]
  );

  const sendBare = useCallback(
    async (command: string): Promise<void> => {
      const api = apiRef.current;
      if (!api) throw new Error('widget transport not ready');
      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, t]
  );

  // Optimistic transition + rollback on send failure. Cancel is panic-UX —
  // we want immediate disconnected state. If the send fails, the bot just
  // doesn't know we cancelled; its CommandState times out eventually.
  const sendCancel = useCallback(async () => {
    dispatch({ kind: 'cancel_pending' });
    try {
      await sendBare('cancel');
    } catch {
      /* already showing disconnected; transcript carries the failure */
    }
  }, [sendBare]);

  // Optimistic awaiting_phone + rollback to disconnected on send failure.
  // Without rollback the user would see the phone form open with no command
  // ever delivered to the bot.
  const onClickLogin = useCallback(async () => {
    dispatch({ kind: 'start_login' });
    try {
      await sendBare('login phone');
    } catch {
      dispatch({ kind: 'cancel_pending' });
    }
  }, [sendBare]);

  const onClickRefresh = useCallback(() => {
    sendBare('list-logins').catch(() => {
      /* transcript carries the failure */
    });
  }, [sendBare]);

  // Optimistic logging_out + recovery on send failure: refire list-logins
  // so the reducer recalibrates from the bridge's truth instead of leaving
  // the UI stuck in logging_out forever.
  const onConfirmLogout = useCallback(
    async (loginId: string) => {
      dispatch({ kind: 'request_logout', loginId });
      try {
        await sendBare(`logout ${loginId}`);
      } catch {
        sendBare('list-logins').catch(() => {
          /* nothing more we can do — user can hit refresh */
        });
      }
    },
    [sendBare]
  );

  const formProps: FormProps = {
    state,
    t,
    dispatch,
    send,
    sendCancel,
    phoneCooldownEnd,
    setPhoneCooldownEnd,
  };

  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, with the
       * «Настроить» dropdown that controls show-chat / mark-read /
       * notifications / leave-room. The widget body starts with the
       * action-relevant section for the current state. */}

      {handshakeOk && state.kind === 'unknown' ? (
        <section class="section">
          <div class="section-recovery-row">
            <span class="section-status checking" role="status">
              <span class="dot" />
              {t('status.unknown')}
            </span>
            {/* Recovery affordance — without this the user stares at the
             * «Проверка статуса…» pill forever if the initial
             * list-logins reply was dropped on the wire. */}
            <button type="button" class="recovery-action" onClick={onClickRefresh}>
              <RefreshIcon />
              {t('card.refresh.label')}
            </button>
          </div>
        </section>
      ) : null}

      {handshakeOk && state.kind === 'disconnected' ? (
        <section class="section">
          {/* Status pill replaces the section header — the pill itself
           * carries the section's identity («Войдите в Telegram» says
           * what this surface is for and what state we're in, so a
           * separate «Подключение» label was redundant). */}
          <span class="section-status disconnected" role="status">
            <span class="dot" />
            {t('status.disconnected')}
          </span>
          <div class="connect-row">
            <div class="command-grid">
              <button class="command-card" type="button" onClick={onClickLogin}>
                <div class="command-card-body">
                  <div class="command-card-name">{t('card.login.name')}</div>
                  <div class="command-card-desc">{t('card.login.desc')}</div>
                </div>
                <span class="command-card-chevron" aria-hidden="true">
                  ›
                </span>
              </button>
            </div>
            <button
              type="button"
              class="refresh-button"
              onClick={onClickRefresh}
              aria-label={t('card.refresh.aria')}
            >
              <RefreshIcon />
            </button>
          </div>
          <p class="hint">{t('landing.hint')}</p>
        </section>
      ) : null}

      {state.kind === 'awaiting_phone' ? (
        <section class="section">
          <PhoneForm {...formProps} />
        </section>
      ) : null}
      {state.kind === 'awaiting_code' ? (
        <section class="section">
          <CodeForm {...formProps} />
        </section>
      ) : null}
      {state.kind === 'awaiting_password' ? (
        <section class="section">
          <PasswordForm {...formProps} />
        </section>
      ) : null}

      {state.kind === 'logging_out' ? (
        <section class="section">
          <div class="section-recovery-row">
            <span class="section-status checking" role="status">
              <span class="dot" />
              {t('status.logging-out')}
            </span>
            {/* If the bot's `Logged out` reply never arrives, refresh
             * fires `list-logins` and the reducer recalibrates from
             * bridge truth (gets routed back to disconnected/connected
             * via logins_listed/not_logged_in). Without this there's no
             * way out of `logging_out` short of a page reload. */}
            <button type="button" class="recovery-action" onClick={onClickRefresh}>
              <RefreshIcon />
              {t('card.refresh.label')}
            </button>
          </div>
        </section>
      ) : null}

      {state.kind === 'connected' ? (
        <section class="section">
          {state.loginId ? (
            <span class="section-status connected" role="status">
              <span class="dot" />
              {state.handle
                ? t('status.connected-as', { handle: state.handle })
                : t('status.connected')}
            </span>
          ) : (
            <div class="section-recovery-row">
              <span class="section-status connected" role="status">
                <span class="dot" />
                {state.handle
                  ? t('status.connected-as', { handle: state.handle })
                  : t('status.connected')}
              </span>
              {/* Visible refresh when we don't yet have a loginId. The
               * post-login_success list-logins is the normal source —
               * if it dropped, the logout card stays disabled forever
               * with only an invisible tooltip. Surface the recovery
               * action explicitly so the user isn't trapped. */}
              <button type="button" class="recovery-action" onClick={onClickRefresh}>
                <RefreshIcon />
                {t('card.refresh.label')}
              </button>
            </div>
          )}
          <p class="hint">{t('auth-card.code.privacy-hint-history')}</p>
          <div class="command-grid">
            <LogoutCard loginId={state.loginId} t={t} onConfirm={onConfirmLogout} />
          </div>
        </section>
      ) : null}

      <section class="section">
        <h2 class="section-label">{t('section.transcript')}</h2>
        <div ref={transcriptRef} class="transcript" role="log" aria-live="polite">
          {transcript.length === 0 ? (
            <div class="transcript-empty">{/* placeholder kept blank intentionally */}</div>
          ) : (
            transcript.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>
  );
}
