// Login flow over the bridgev2 v3 login API. The bridge owns the step
// machine (provisioninglogin.go); we render whatever step it returns:
//
//   phone flow:  user_input(phone_number) → user_input(2fa_code)
//                → [user_input(password)] → complete
//   qr flow:     display_and_wait(qr) —long-poll→ rotated qr | password | complete
//
// `.incorrect` step_id variants (wrong code / wrong password) keep the login
// process alive — the form stays open with an inline error. Every OTHER step
// error kills the process server-side (doLoginStep deletes it), so the flow
// resets; for the phone form we keep the typed number on screen and
// transparently start a fresh process on resubmit.

import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import qrcodeGenerator from 'qrcode-generator';
// `/min` metadata (~15 KB gzip) covers all country calling codes + length
// validation. Sufficient for «is this a plausible phone number?» — the
// bridge does the authoritative validation server-side.
import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min';
import {
  ProvisioningClient,
  ProvisioningError,
  TG_FLOW_PHONE,
  TG_FLOW_QR,
  isNotFound,
  type LoginStep,
} from './provisioning';
import { describeApiError } from './errors';
import { EyeBlindIcon, EyeIcon } from './ui';
import type { T } from './i18n';

// --- Flow state -------------------------------------------------------------

export type LoginFormKind = 'phone' | 'code' | 'password';

export type LoginUi =
  | { kind: 'idle' }
  | { kind: 'starting'; flow: 'phone' | 'qr' }
  | {
      kind: 'form';
      form: LoginFormKind;
      loginId: string;
      stepId: string;
      fieldId: string;
      busy: boolean;
      error?: string;
      /** The server-side login process died (its errors are terminal) — the
       * next submit transparently starts a fresh process. Phone form only. */
      needsRestart?: boolean;
    }
  | { kind: 'qr'; loginId: string; stepId: string; url: string };

type LoginFlowCallbacks = {
  /** A `complete` step landed — refresh whoami and celebrate. */
  onComplete: () => void;
  /** Terminal flow error to surface outside the (now closed) form. */
  onError: (message: string) => void;
};

export type LoginFlow = {
  ui: LoginUi;
  start: (flow: 'phone' | 'qr') => void;
  submit: (value: string) => void;
  cancel: () => void;
  /** Phone-submit cooldown deadline (SMS rate-limit guard), null when idle. */
  phoneCooldownEnd: number | null;
  /** Last phone number the user typed (display-formatted) — survives
   * cancel→reopen so retrying during the SMS cooldown doesn't force
   * retyping. */
  lastPhone: string;
  rememberPhone: (value: string) => void;
};

// Telegram throttles repeat SMS hard. 60 s matches Telegram Desktop's own
// "Resend code" lockout. Armed only when the bridge confirms it dispatched a
// code (the submit resolved into the code step).
const PHONE_COOLDOWN_MS = 60_000;

// --- Wait-loop resilience ----------------------------------------------------
// The QR long-poll dies whenever Android freezes the backgrounded WebView —
// and scanning the QR with the Telegram app on the SAME phone requires
// leaving Vojo, which kills the TCP connection within ~15 s. Since mautrix
// v0.28.1 the login process lives server-side with a 30-minute TTL
// (provisioninglogin.go: Wait runs on login.Ctx, not the request context),
// so a dropped poll is reattachable: the loop retries transport-shaped
// failures with backoff and wakes early when the tab returns to the
// foreground.

const WAIT_RETRY_BASE_MS = 2_000;
const WAIT_RETRY_MAX_MS = 15_000;

// Transport-shaped failures: fetch network death (TypeError), an abort that
// is NOT ours (the frozen WebView tearing the connection down, or the
// per-attempt poll timeout), or an errcode-LESS 5xx — a proxy-shaped
// 502/503/504 from Caddy with a non-JSON body. Anything carrying an errcode
// is a RespError the bridge itself wrote and is an authoritative verdict on
// the login — NOT retryable. Notably M_BAD_STATE (a background QR scan on a
// 2FA account advanced the step machine to the password step while we were
// frozen) and the framework's LOGIN_TIMED_OUT ship as HTTP 500, so a bare
// status check would retry a dead step id forever.
const isTransientWaitError = (err: unknown): boolean => {
  if (err instanceof ProvisioningError) {
    return err.httpStatus >= 500 && err.errcode === undefined;
  }
  return err instanceof Error && (err.name === 'TypeError' || err.name === 'AbortError');
};

// Backoff delay that resolves early when the document becomes visible again
// (snappy reattach after returning from the Telegram app) or when the loop
// is aborted (the caller re-checks the signal and exits).
const waitBeforeRetry = (ms: number, signal: AbortSignal): Promise<void> =>
  new Promise((resolve) => {
    let timer: number | null = null;
    let cleanup = () => {};
    const done = () => {
      cleanup();
      resolve();
    };
    const onVisible = () => {
      if (document.visibilityState === 'visible') done();
    };
    cleanup = () => {
      if (timer !== null) window.clearTimeout(timer);
      document.removeEventListener('visibilitychange', onVisible);
      signal.removeEventListener('abort', done);
    };
    timer = window.setTimeout(done, ms);
    document.addEventListener('visibilitychange', onVisible);
    signal.addEventListener('abort', done);
  });

export const useLoginFlow = (
  client: ProvisioningClient,
  t: T,
  callbacks: LoginFlowCallbacks
): LoginFlow => {
  const [ui, setUi] = useState<LoginUi>({ kind: 'idle' });
  const [phoneCooldownEnd, setPhoneCooldownEnd] = useState<number | null>(null);

  // Latest-wins guards for async work. Bumping the generation invalidates
  // every in-flight continuation (QR long-poll loop, submit handlers);
  // aborting the controller actually cancels the poll's fetch.
  const generation = useRef(0);
  const waitAbort = useRef<AbortController | null>(null);
  const lastPhoneRef = useRef('');

  const callbacksRef = useRef(callbacks);
  callbacksRef.current = callbacks;

  useEffect(
    () => () => {
      generation.current += 1;
      waitAbort.current?.abort();
    },
    []
  );

  return useMemo<LoginFlow>(() => {
    const formFromField = (fieldType: string | undefined): LoginFormKind | null => {
      if (fieldType === 'phone_number') return 'phone';
      if (fieldType === '2fa_code') return 'code';
      if (fieldType === 'password') return 'password';
      return null;
    };

    const runQrWaitLoop = (loginId: string, firstStepId: string, gen: number): void => {
      waitAbort.current?.abort();
      const controller = new AbortController();
      waitAbort.current = controller;
      void (async () => {
        let stepId = firstStepId;
        let retryDelayMs = WAIT_RETRY_BASE_MS;
        for (;;) {
          let step: LoginStep;
          try {
            // Blocks server-side until the QR token rotates (~30 s), the
            // scan succeeds, or the login dies.
            step = await client.loginWait(loginId, stepId, controller.signal);
            retryDelayMs = WAIT_RETRY_BASE_MS;
          } catch (err) {
            if (gen !== generation.current || controller.signal.aborted) return;
            if (isTransientWaitError(err)) {
              // The panel stays up; the next attempt either reattaches to
              // the still-alive server-side step or gets an authoritative
              // 4xx and ends the flow honestly.
              await waitBeforeRetry(retryDelayMs, controller.signal);
              if (gen !== generation.current || controller.signal.aborted) return;
              retryDelayMs = Math.min(retryDelayMs * 2, WAIT_RETRY_MAX_MS);
              continue;
            }
            // A 404 (or ALREADY_FINISHED) can mean two opposite things: the
            // window expired, or the login COMPLETED while the WebView was
            // frozen — a finished login is removed from the registry, so a
            // late re-poll can't tell the difference. whoami is the
            // authority on which way it went.
            const gone =
              isNotFound(err) ||
              (err instanceof ProvisioningError &&
                err.errcode === 'FI.MAU.BRIDGE.LOGIN_ALREADY_FINISHED');
            if (gone) {
              // The probe itself retries transport blips (a completed login
              // must not be reported as «timed out» because one whoami GET
              // hit a dead network); any authoritative answer breaks out.
              let probeDelayMs = WAIT_RETRY_BASE_MS;
              for (;;) {
                try {
                  const whoami = await client.whoami();
                  if (gen !== generation.current || controller.signal.aborted) return;
                  if (whoami.logins && whoami.logins.length > 0) {
                    setUi({ kind: 'idle' });
                    callbacksRef.current.onComplete();
                    return;
                  }
                  break; // authoritative «no login» — the window really expired
                } catch (probeErr) {
                  if (gen !== generation.current || controller.signal.aborted) return;
                  if (!isTransientWaitError(probeErr)) break;
                  await waitBeforeRetry(probeDelayMs, controller.signal);
                  if (gen !== generation.current || controller.signal.aborted) return;
                  probeDelayMs = Math.min(probeDelayMs * 2, WAIT_RETRY_MAX_MS);
                }
              }
            } else {
              // The login may still be alive server-side (e.g. M_BAD_STATE
              // desync after a background 2FA scan) — free the 30-minute
              // slot. Best-effort; the cancel route exists on this bridge.
              void client.loginCancel(loginId).catch(() => undefined);
            }
            setUi({ kind: 'idle' });
            callbacksRef.current.onError(
              gone ? t('error.login-timeout') : describeApiError(err, t)
            );
            return;
          }
          if (gen !== generation.current) return;
          if (step.type === 'display_and_wait') {
            const data = step.display_and_wait?.data;
            if (step.display_and_wait?.type === 'qr' && data) {
              stepId = step.step_id;
              setUi({ kind: 'qr', loginId, stepId: step.step_id, url: data });
              continue;
            }
            setUi({ kind: 'idle' });
            callbacksRef.current.onError(t('error.generic', { reason: step.step_id }));
            return;
          }
          applyStep(step, gen);
          return;
        }
      })();
    };

    const applyStep = (step: LoginStep, gen: number): void => {
      if (gen !== generation.current) return;
      if (step.type === 'complete') {
        setUi({ kind: 'idle' });
        callbacksRef.current.onComplete();
        return;
      }
      if (step.type === 'user_input') {
        const field = step.user_input?.fields?.[0];
        const form = formFromField(field?.type);
        if (!field || !form) {
          setUi({ kind: 'idle' });
          callbacksRef.current.onError(t('error.generic', { reason: step.step_id }));
          void client.loginCancel(step.login_id).catch(() => undefined);
          return;
        }
        const error = step.step_id.endsWith('.incorrect')
          ? t(form === 'password' ? 'error.password-incorrect' : 'error.code-incorrect')
          : undefined;
        setUi({
          kind: 'form',
          form,
          loginId: step.login_id,
          stepId: step.step_id,
          fieldId: field.id,
          busy: false,
          error,
        });
        return;
      }
      if (step.type === 'display_and_wait') {
        const data = step.display_and_wait?.data;
        if (step.display_and_wait?.type === 'qr' && data) {
          setUi({ kind: 'qr', loginId: step.login_id, stepId: step.step_id, url: data });
          runQrWaitLoop(step.login_id, step.step_id, gen);
          return;
        }
      }
      // cookies / unknown display types — nothing the Telegram connector
      // ships today. Bail out coherently instead of rendering nothing.
      setUi({ kind: 'idle' });
      callbacksRef.current.onError(t('error.generic', { reason: step.step_id }));
      void client.loginCancel(step.login_id).catch(() => undefined);
    };

    const start = (flow: 'phone' | 'qr'): void => {
      generation.current += 1;
      const gen = generation.current;
      setUi({ kind: 'starting', flow });
      void client
        .loginStart(flow === 'phone' ? TG_FLOW_PHONE : TG_FLOW_QR)
        .then((step) => {
          if (gen !== generation.current) {
            // User cancelled while the start was in flight — don't leave an
            // orphaned login process on the bridge for its 30-minute TTL.
            void client.loginCancel(step.login_id).catch(() => undefined);
            return;
          }
          applyStep(step, gen);
        })
        .catch((err) => {
          if (gen !== generation.current) return;
          setUi({ kind: 'idle' });
          callbacksRef.current.onError(describeApiError(err, t));
        });
    };

    const submit = (value: string): void => {
      if (ui.kind !== 'form' || ui.busy) return;
      const snapshot = ui;
      const gen = generation.current;
      setUi({ ...snapshot, busy: true, error: undefined });
      void (async () => {
        try {
          let step: LoginStep;
          if (snapshot.needsRestart && snapshot.form === 'phone') {
            // Previous process died on a terminal error — restart
            // transparently so «fix the typo and resubmit» just works.
            const fresh = await client.loginStart(TG_FLOW_PHONE);
            if (gen !== generation.current) {
              // Cancelled while the restart was in flight — don't go on to
              // submit the number and trigger an SMS for a flow nobody is
              // looking at.
              void client.loginCancel(fresh.login_id).catch(() => undefined);
              return;
            }
            const freshField = fresh.user_input?.fields?.[0];
            if (fresh.type !== 'user_input' || !freshField) {
              applyStep(fresh, gen);
              return;
            }
            step = await client.loginSubmitInput(fresh.login_id, fresh.step_id, {
              [freshField.id]: value,
            });
          } else {
            step = await client.loginSubmitInput(snapshot.loginId, snapshot.stepId, {
              [snapshot.fieldId]: value,
            });
          }
          // The phone-number submit resolving into a user_input step means
          // Telegram dispatched a code — arm the SMS-resend cooldown BEFORE
          // the stale-generation check: a cancel that raced the submit
          // doesn't un-send the SMS, and the cooldown must survive
          // cancel→retry (that's its whole job).
          if (snapshot.form === 'phone' && step.type === 'user_input') {
            setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS);
          }
          if (gen !== generation.current) return;
          applyStep(step, gen);
        } catch (err) {
          if (gen !== generation.current) return;
          const message = describeApiError(err, t);
          if (snapshot.form === 'phone') {
            setUi({ ...snapshot, busy: false, error: message, needsRestart: true });
          } else {
            // Code/password context is gone server-side; reopening the
            // form would submit into a deleted process.
            setUi({ kind: 'idle' });
            callbacksRef.current.onError(message);
          }
        }
      })();
    };

    const cancel = (): void => {
      generation.current += 1;
      waitAbort.current?.abort();
      // 'starting' has no loginId yet — the stale-generation check in
      // start().then() cancels the just-created process when it lands.
      // phoneCooldownEnd deliberately SURVIVES cancel: it guards Telegram's
      // SMS rate limit, and a cancel→restart loop must not reset it (the
      // submit button labels the remaining wait, so this reads as intended).
      const loginId = ui.kind === 'form' || ui.kind === 'qr' ? ui.loginId : undefined;
      if (loginId) void client.loginCancel(loginId).catch(() => undefined);
      setUi({ kind: 'idle' });
    };

    return {
      ui,
      start,
      submit,
      cancel,
      phoneCooldownEnd,
      lastPhone: lastPhoneRef.current,
      rememberPhone: (value: string) => {
        lastPhoneRef.current = value;
      },
    };
    // `ui` MUST stay in the deps: submit/cancel read it via closure capture
    // (not refs), so dropping it would freeze them on a stale snapshot.
    // Regenerating the flow object per ui change is cheap — nothing
    // downstream memoizes on its identity.
  }, [client, t, ui, phoneCooldownEnd]);
};

// --- Shared form helpers ------------------------------------------------------

// Hint shown when a submit round-trip is slow (Telegram-side latency).
const STILL_WAITING_DELAY_MS = 8_000;

const useStillWaiting = (active: boolean): boolean => {
  const [show, setShow] = useState(false);
  useEffect(() => {
    setShow(false);
    if (!active) return undefined;
    const timer = window.setTimeout(() => setShow(true), STILL_WAITING_DELAY_MS);
    return () => window.clearTimeout(timer);
  }, [active]);
  return show;
};

// Tick once per second while a future timestamp is still in the future.
export 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`;
    // the effect only needs to re-run when `until` itself changes.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [until]);
  return seconds;
};

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

// --- Phone form ---------------------------------------------------------------

// Minimum digit count before we'd dare call a number «invalid» — below this
// the user is still typing the country prefix.
const PHONE_MIN_DIGITS_FOR_VALIDATION = 7;

const phoneToE164 = (raw: string): string => {
  const cleaned = raw.replace(/[^\d+]/g, '');
  if (cleaned.length === 0) return '';
  return cleaned.startsWith('+') ? cleaned : `+${cleaned}`;
};

// AsYouType is stateful — use a fresh instance per call so mid-string edits
// (paste, backspace) can't desync the formatter from the input value.
type PhoneFormat = { formatted: string; country: string | undefined };
const formatPhoneInput = (raw: string): PhoneFormat => {
  const e164 = phoneToE164(raw);
  if (!e164) return { formatted: '', country: undefined };
  const formatter = new AsYouType();
  const formatted = formatter.input(e164);
  return { formatted, country: formatter.getCountry() };
};

// ISO 3166-1 alpha-2 → regional-indicator emoji. 'RU' → 🇷🇺.
const countryToFlagEmoji = (cc: string | undefined): string => {
  if (!cc || cc.length !== 2) return '';
  const codePoints = cc
    .toUpperCase()
    .split('')
    .map((c) => 127397 + c.charCodeAt(0));
  return String.fromCodePoint(...codePoints);
};

type FormProps = {
  flow: LoginFlow;
  t: T;
};

export const PhoneForm = ({ flow, t }: FormProps) => {
  const ui = flow.ui.kind === 'form' ? flow.ui : null;
  // Pre-fill the number the user typed in a previous attempt — a cancel
  // during the SMS cooldown shouldn't cost them the input.
  const [value, setValue] = useState(() => flow.lastPhone);
  const [country, setCountry] = useState<string | undefined>(() =>
    flow.lastPhone ? formatPhoneInput(flow.lastPhone).country : undefined
  );
  const inputRef = useRef<HTMLInputElement | null>(null);
  const busy = ui?.busy ?? false;
  const stillWaiting = useStillWaiting(busy);
  const cooldownSeconds = useCooldownSeconds(flow.phoneCooldownEnd);
  const inCooldown = cooldownSeconds > 0;

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

  const e164 = phoneToE164(value);
  const digitsCount = e164.replace('+', '').length;
  const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION;
  // Soft hint, not a hard gate — libphonenumber metadata lags newly
  // allocated pools and the bridge has the authoritative word.
  const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164);

  const onSubmit = (event: Event) => {
    event.preventDefault();
    if (!e164 || busy || inCooldown || !hasEnoughDigits) return;
    flow.rememberPhone(value);
    flow.submit(e164);
  };

  const submitLabel = inCooldown
    ? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
    : t('auth-card.phone.submit');
  const flagEmoji = countryToFlagEmoji(country);
  const error = ui?.error;

  return (
    <form class={`auth-card${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">
        <div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
          {flagEmoji ? (
            <span class="auth-phone-flag" aria-hidden="true">
              {flagEmoji}
            </span>
          ) : null}
          <input
            id="auth-phone-input"
            ref={inputRef}
            class={`auth-input${showInvalidHint ? ' warn' : ''}`}
            type="tel"
            autocomplete="tel"
            inputmode="tel"
            placeholder={t('auth-card.phone.placeholder')}
            value={value}
            onInput={(e) => {
              const raw = (e.currentTarget as HTMLInputElement).value;
              const next = formatPhoneInput(raw);
              setValue(next.formatted);
              setCountry(next.country);
            }}
            disabled={busy}
          />
        </div>
        <button type="submit" class="btn-primary" disabled={busy || inCooldown || !hasEnoughDigits}>
          {submitLabel}
        </button>
        <button type="button" class="btn-text" onClick={flow.cancel}>
          {t('auth-card.cancel')}
        </button>
      </div>
      <div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
      {showInvalidHint && !error ? (
        <div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
      ) : null}
      {error ? <div class="auth-card-error">{error}</div> : null}
      {busy && stillWaiting ? (
        <div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
      ) : null}
    </form>
  );
};

// --- Code form ----------------------------------------------------------------

const CODE_COUNTDOWN_SECONDS = 30;

export const CodeForm = ({ flow, t }: FormProps) => {
  const ui = flow.ui.kind === 'form' ? flow.ui : null;
  const [value, setValue] = useState('');
  const inputRef = useRef<HTMLInputElement | null>(null);
  const busy = ui?.busy ?? false;
  const stillWaiting = useStillWaiting(busy);
  const countdownSeconds = useCountdown(CODE_COUNTDOWN_SECONDS);
  const error = ui?.error;

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

  const onSubmit = (event: Event) => {
    event.preventDefault();
    const trimmed = value.trim();
    if (!trimmed || busy) return;
    setValue('');
    flow.submit(trimmed);
  };

  return (
    <form class={`auth-card${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={busy}
        />
        <button type="submit" class="btn-primary" disabled={busy || value.trim() === ''}>
          {t('auth-card.code.submit')}
        </button>
        <button type="button" class="btn-text" onClick={flow.cancel}>
          {t('auth-card.cancel')}
        </button>
      </div>
      {error ? <div class="auth-card-error">{error}</div> : null}
      {/* SMS countdown is suppressed while submitting (the bridge-latency
       * hint takes over) AND after a wrong-code error — by then the code
       * already arrived, so «код придёт через N сек» / «не пришло» copy
       * would contradict the error line right above it. */}
      {!busy &&
        !error &&
        (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>
        ))}
      {busy && stillWaiting ? (
        <div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
      ) : null}
    </form>
  );
};

// --- Password form --------------------------------------------------------------

export const PasswordForm = ({ flow, t }: FormProps) => {
  const ui = flow.ui.kind === 'form' ? flow.ui : null;
  const [value, setValue] = useState('');
  const [reveal, setReveal] = useState(false);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const busy = ui?.busy ?? false;
  const stillWaiting = useStillWaiting(busy);
  const error = ui?.error;

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

  const onSubmit = (event: Event) => {
    event.preventDefault();
    if (!value || busy) return;
    // Drop the plaintext from component state BEFORE the async submit so it
    // doesn't linger in the DOM input while the request is in flight.
    const password = value;
    setValue('');
    flow.submit(password);
  };

  return (
    <form class={`auth-card${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="auth-password-shell">
          <input
            id="auth-password-input"
            ref={inputRef}
            class="auth-input password"
            type={reveal ? 'text' : 'password'}
            autocomplete="current-password"
            // Kill the HTML default size=20 intrinsic width so flex can
            // shrink the input on narrow viewports (see styles.css notes).
            size={1}
            value={value}
            onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
            disabled={busy}
          />
          <button
            type="button"
            class="auth-password-eye"
            onClick={() => setReveal((v) => !v)}
            aria-label={reveal ? t('auth-card.password.hide') : t('auth-card.password.show')}
            aria-pressed={reveal}
            aria-controls="auth-password-input"
            disabled={busy}
          >
            {reveal ? <EyeIcon /> : <EyeBlindIcon />}
          </button>
        </div>
        <button type="submit" class="btn-primary" disabled={busy || value === ''}>
          {t('auth-card.password.submit')}
        </button>
        <button type="button" class="btn-text" onClick={flow.cancel}>
          {t('auth-card.cancel')}
        </button>
      </div>
      {error ? <div class="auth-card-error">{error}</div> : null}
      {busy && stillWaiting ? (
        <div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
      ) : null}
    </form>
  );
};

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

// bridgev2's server-side login deadline for the telegram connector is 10
// minutes (LoginTimeout, loginqr.go). Soft countdown — at zero we surface a
// retry hint; the server kills the process on its own.
const QR_TIMEOUT_MS = 10 * 60 * 1000;

// Error-correction level M: more glare-resilient than L, smaller modules
// than Q — matches Telegram Desktop's own QR-login screen.
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>s inside an SVG. No dangerouslySetInnerHTML,
// no external rendering service — the `tg://login?token=...` URL IS the login
// secret and must never leave the iframe.
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 to kill 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 = {
  url: string;
  t: T;
  onCancel: () => void;
};

export const QrPanel = ({ url, t, onCancel }: QrPanelProps) => {
  // First-shown timestamp survives QR rotations — the component stays
  // mounted while only `url` changes, and the countdown tracks the WHOLE
  // login window, not the validity of one displayed token.
  const [firstShownAt] = useState(() => Date.now());
  const [now, setNow] = useState(() => Date.now());
  useEffect(() => {
    const timer = window.setInterval(() => setNow(Date.now()), 1000);
    return () => window.clearInterval(timer);
  }, []);

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

  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 of the QR, not its contents —
          // the URL itself is the login secret.
          <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>
      <div class="auth-card-row">
        <button type="button" class="btn-text" onClick={onCancel}>
          {t('auth-card.cancel')}
        </button>
      </div>
    </div>
  );
};
