import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Keyboard, KeyboardAvoidingView, Platform, ScrollView, useWindowDimensions, View, } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import Animated, { Easing, LinearTransition, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated";
import { withClickTracking } from "../../services/telemetry";
import { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons";
import { useColorScheme } from "nativewind";
import * as Haptics from "expo-haptics";
import { Heading, MutedText, Text } from "../../components/text";
import { Button, IconButton } from "../../components/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "../../components/card";
import { EmailInput, Input, PasswordInput } from "../../components/input";
import { Dialog, DialogButton, DialogFooter } from "../../components/dialog";
import { SeparatorWithText } from "../../components/separator";
import { LegalDialog } from "../../components/legal-dialog";
import { SegmentedToggle } from "../../components/segmented-toggle";
import PasswordStrengthMeter from "../../components/auth/PasswordStrengthMeter";
import PwnedPasswordWarning from "../../components/auth/PwnedPasswordWarning";
import ShakeField from "../../forms/shake-field";
import FormError from "../../forms/form-error";
import { useAuth } from "../../hooks/useAuth";
import { useShakeAnimation } from "../../hooks/useShakeAnimation";
import { ApiError } from "../../utils/http-error";
import { validateEmail } from "../../utils/validation";
import AuthService from "../../services/auth.service";
import { checkPasswordPwned, debounce, getPwnedMessage } from "../../services/hibp";
import { getCachedPolicy } from "../../services/hibp-cache";
import { useAppConfig } from "../../context/AppConfigContext";
import { useNavigation } from "../../context/NavigationContext";
const SCROLL_CONTENT_STYLE = { flexGrow: 1 };
/**
 * Overhang added below the form while the software keyboard is up.
 *
 * Enough to lift the last field and its button clear of the keyboard without
 * leaving a visible gap once it closes — the style is only applied while the
 * keyboard is showing.
 */
const KEYBOARD_SCROLL_PADDING = { paddingBottom: 160 };
/** Width of the form column when the aside is showing. */
/**
 * Width of the form column when the aside is showing.
 *
 * With symmetric padding the card centres in this column, so the gap either
 * side of it is simply `(FORM_PANE_WIDTH - 360) / 2`. 716 puts 178px between
 * the card and the seam, and the same 178px between the card and the window
 * edge — the two gaps match by construction rather than by tuning two numbers
 * against each other.
 */
const FORM_PANE_WIDTH = 716;
const DEFAULT_POLICY = { enabled: false, enforce: false, block_threshold: 0 };
const DEFAULT_TRANSITION = {
    // Height is locked to the tallest mode (see the form-box note below), so the
    // layout transition has nothing left to ease. Kept for the cases where the
    // box legitimately grows past the lock — a pwned-password warning, a wrapped
    // error, a larger system font.
    layout: LinearTransition.duration(280),
    // No enter/exit animation. Two earlier attempts were both wrong: the
    // directional FadeInDown/FadeOutUp slid fields in from above, and a plain
    // cross-fade still singled out the only fields that actually mount on a mode
    // switch — Display Name, Confirm Password — so they arrived visibly later
    // than the fields around them, which are already on screen and simply stay
    // put. One field fading in while its neighbours do not reads as a glitch, not
    // as a transition. The card's size is locked, so an instant swap has nothing
    // to hide anyway.
    //
    // Overridable: `transition` on the props still accepts both, for a consumer
    // that wants them.
    entering: undefined,
    exiting: undefined,
};
// Stage morph timing — fade/scale out, swap the stage at zero opacity (so the
// centered→centered swap is invisible, no overlap/teleport), then fade/scale in.
const STAGE_OUT_MS = 200;
const STAGE_IN_MS = 360;
/**
 * Single consolidated unauthenticated screen with three animated stages:
 * a welcome view (logo + app decoration + Get Started), the Sign In / Sign Up
 * form (with an animated mode toggle), and inline forgot-password. Terms/Privacy
 * are themed popups and a connection-settings dialog can be folded in. Preserves
 * the passkey ceremony + non-PRF unlock and the HIBP/strength checks.
 */
export function AuthScreen({ passwordLabel = "Password", passwordPlaceholder = "Enter your password", socialAuth, passkeyEnabled = false, passkeyUnlockPlaceholder = "Enter your master password", passkeyUnlockTitle = "Unlock your vault", passkeyUnlockDescription = "Signed in successfully. This passkey can't unlock the vault on its own — enter your master password to decrypt your data.", settings, onSettingsPress, transition, decoration, footer, aside, asideBreakpoint = 1024, lockFormHeight = true, backdrop, cardBackdrop, cardBackdropBleed = 48, welcomeCtaLabel = "Get Started", onWelcomeDismissed, signUpEnabled = true, forgotPasswordEnabled = true, requireInviteCode = false, onSignUp, signUpSuccessMessage = "Account created. Check your email to verify the address, then sign in.", showWelcome = true, title, subtitle, } = {}) {
    const { login: authLogin, register: authRegister, loginWithPasskey, unlockWithPassword, } = useAuth();
    const router = useNavigation();
    const { appName, Logo, tagline, taglineLayout, routes, termsText, privacyText, logoIsWordmark, logoWidth, } = useAppConfig();
    const { colorScheme } = useColorScheme();
    const isDark = colorScheme === "dark";
    // Two-pane only when there is a pane to show AND room to show it.
    const { width: viewportWidth } = useWindowDimensions();
    const showAside = !!aside && viewportWidth >= asideBreakpoint;
    // A wordmark has no app-name text to sit beside, so the two layouts that
    // need one degrade to the ruled treatment rather than rendering a pipe, or a
    // lockup, against nothing.
    const inlineTagline = taglineLayout === "inline" && !logoIsWordmark;
    const lockupBrand = taglineLayout === "lockup" && !logoIsWordmark;
    const ruledTagline = taglineLayout !== undefined && !inlineTagline && !lockupBrand;
    // The lockup sizes the mark to the measured height of the name + tagline
    // block, so the three line up however the app scales its type. Measured
    // rather than hard-coded because a fixed px value drifts the moment the type
    // scale or the system font size moves.
    /**
     * Scrolls the form so the field being typed into is actually visible.
     *
     * On a phone the sign-up form is taller than the space left once the
     * keyboard is up, and the fields near the bottom — password, confirm — sat
     * underneath it. `adjustResize` shrinks the window, but the content inside
     * this ScrollView is pinned to exactly the viewport height (`flexGrow: 1`
     * around a `flex-1 justify-between` view), so there was nothing to scroll:
     * the layout just compressed and the bottom fields stayed hidden.
     *
     * Two changes fix it together — the extra bottom room added to the content
     * style below, and this scroll once the keyboard has actually appeared.
     * `keyboardDidShow`, not `WillShow`, because Android only fires the former
     * and the height is not final until it does.
     */
    const scrollRef = useRef(null);
    const [keyboardVisible, setKeyboardVisible] = useState(false);
    useEffect(() => {
        // Web has no software keyboard to avoid, and the desktop split-pane layout
        // must not start scrolling itself when a field is focused.
        if (Platform.OS === "web") {
            return;
        }
        const show = Keyboard.addListener("keyboardDidShow", () => {
            setKeyboardVisible(true);
            // A frame later: the ScrollView has to re-measure against the shrunken
            // viewport before scrollToEnd knows where the end is.
            requestAnimationFrame(() => {
                scrollRef.current?.scrollToEnd({ animated: true });
            });
        });
        const hide = Keyboard.addListener("keyboardDidHide", () => {
            setKeyboardVisible(false);
        });
        return () => {
            show.remove();
            hide.remove();
        };
    }, []);
    const [brandTextHeight, setBrandTextHeight] = useState(0);
    const onBrandTextLayout = useCallback((e) => {
        const h = Math.round(e.nativeEvent.layout.height);
        setBrandTextHeight((prev) => (Math.abs(prev - h) > 1 ? h : prev));
    }, []);
    const baseTransition = useMemo(() => ({ ...DEFAULT_TRANSITION, ...transition }), [transition]);
    const [mode, setMode] = useState("signin");
    const [inviteCode, setInviteCode] = useState("");
    const [signedUp, setSignedUp] = useState(null);
    const [stage, setStage] = useState(showWelcome ? "welcome" : "auth");
    const isSignup = mode === "signup";
    // Stage morph: `stage` is the target; `displayStage` is what's rendered.
    // On a stage change we fade/scale the content out, swap displayStage at zero
    // opacity (so the centered-welcome → top-anchored-form layout change is
    // invisible — no teleport, no overlap of the two states), then fade in.
    const [displayStage, setDisplayStage] = useState(showWelcome ? "welcome" : "auth");
    // Fade-through: `stage` is the target, `displayStage` is what's rendered. On
    // a change the content fades/scales out, swaps at zero opacity (so the
    // position change between stages is never seen), then fades back in.
    const morph = useSharedValue(1);
    useEffect(() => {
        if (stage === displayStage) {
            return;
        }
        morph.value = withTiming(0, { duration: STAGE_OUT_MS, easing: Easing.in(Easing.cubic) }, (done) => {
            if (done) {
                runOnJS(setDisplayStage)(stage);
                morph.value = withTiming(1, { duration: STAGE_IN_MS, easing: Easing.out(Easing.cubic) });
            }
        });
    }, [stage, displayStage, morph]);
    const morphStyle = useAnimatedStyle(() => ({
        opacity: morph.value,
        transform: [{ scale: 0.94 + 0.06 * morph.value }, { translateY: (1 - morph.value) * 8 }],
    }));
    const onWelcome = displayStage === "welcome";
    const [email, setEmail] = useState("");
    const [displayName, setDisplayName] = useState("");
    const [password, setPassword] = useState("");
    const [confirmPassword, setConfirmPassword] = useState("");
    const [isLoading, setIsLoading] = useState(false);
    const [errors, setErrors] = useState({});
    const [legal, setLegal] = useState(null);
    const [settingsOpen, setSettingsOpen] = useState(false);
    const [pwnedInfo, setPwnedInfo] = useState(null);
    const [passkeyStep, setPasskeyStep] = useState("idle");
    const [unlockPassword, setUnlockPassword] = useState("");
    const [unlockError, setUnlockError] = useState("");
    const [pendingOnboardingStep, setPendingOnboardingStep] = useState(null);
    const passkeyAbortRef = useRef(null);
    const [forgotSent, setForgotSent] = useState(false);
    const emailShake = useShakeAnimation();
    const displayNameShake = useShakeAnimation();
    const passwordShake = useShakeAnimation();
    const confirmShake = useShakeAnimation();
    const firstError = errors.email ?? errors.displayName ?? errors.password ?? errors.confirmPassword;
    useEffect(() => () => passkeyAbortRef.current?.abort(), []);
    const checkPasswordDebounced = useMemo(() => debounce(async (pass) => {
        const policy = getCachedPolicy() ?? DEFAULT_POLICY;
        if (!policy.enabled || pass.length < 8) {
            setPwnedInfo(null);
            return;
        }
        const result = await checkPasswordPwned(pass);
        if (result.isPwned) {
            setPwnedInfo({ ...getPwnedMessage(result), count: result.count });
        }
        else {
            setPwnedInfo(null);
        }
    }, 1000), []);
    useEffect(() => () => checkPasswordDebounced.cancel(), [checkPasswordDebounced]);
    const switchMode = useCallback((next) => {
        setMode(next);
        setErrors({});
        setPwnedInfo(null);
    }, []);
    // ── Stable form box ───────────────────────────────────────────────────
    // Sign In and Sign Up mount different field sets (passkey button +
    // separator + "Forgot password?" against Display Name + strength meter +
    // Confirm Password), so the card grew ~70 px on every toggle. On a short
    // viewport the taller Sign Up layout also pushed the page into a scroll,
    // which moved the logo, the footer and the card's own vertical centre —
    // the whole screen appeared to resize, not only the fields.
    //
    // So lock the body to the TALLEST layout and let the shorter mode leave
    // its slack at the bottom of the card. `probeSignup` renders the Sign Up
    // field set for the single frame the form first mounts, purely to measure
    // it: coming from the welcome stage that frame is already at zero opacity
    // (the morph is mid-fade), and it is explicitly hidden anyway for apps
    // that skip the welcome stage. Without that pre-measure the FIRST toggle
    // of every page load would still be the one that grows the card.
    //
    // `minHeight` (not `height`) on purpose: anything that legitimately needs
    // more room later — a pwned-password warning, a wrapped error, a larger
    // system font — still expands the card instead of being clipped.
    //
    // A consumer can turn the lock OFF with `lockFormHeight={false}`, and then
    // the card sizes to whichever mode is showing — the layout animation already
    // wrapping the body eases the height change rather than jumping it. That is
    // the right trade where the reserved slack is the more visible cost: a Sign
    // Up set much taller than Sign In leaves a conspicuous gap above the submit
    // button for every user who only ever signs in, which is most of them.
    const [formMinHeight, setFormMinHeight] = useState(0);
    const [probed, setProbed] = useState(!signUpEnabled || !lockFormHeight);
    const probeSignup = !probed;
    const showSignup = probeSignup || isSignup;
    /**
     * Whether the social block is on screen.
     *
     * A boolean rather than the node itself, because the node's identity changes
     * on every render of the consumer and would re-arm the probe forever.
     */
    const hasSocialAuth = socialAuth !== undefined && socialAuth !== null;
    // Re-arm the probe when anything that changes the form's height ARRIVES LATE.
    //
    // `probed` starts at `!signUpEnabled`, which is right for a consumer that
    // knows its registration policy at mount. One that has to ASK — Access's
    // portal fetches the tenant's policy and starts at the conservative
    // "closed" — mounts with sign-up off, skips the measuring pass, and then
    // turns sign-up on a moment later with nothing measured. The first real
    // toggle is then the one that grows the card: it resizes once, then holds
    // still forever, which is precisely the symptom the lock exists to remove.
    //
    // `socialAuth` is the same story and was missed. Vault asks the server
    // whether federation is configured before it offers the buttons, so the block
    // lands a moment after mount — after the measuring pass had already recorded
    // a form without it. Same symptom, one resize in, then still.
    //
    // Cheap and idempotent: the effect only fires on an edge, and for a consumer
    // whose values were already settled at mount this sets what it already had.
    useEffect(() => {
        if (signUpEnabled && lockFormHeight) {
            setProbed(false);
        }
    }, [signUpEnabled, lockFormHeight, hasSocialAuth]);
    const onFormLayout = useCallback((e) => {
        const measured = Math.ceil(e.nativeEvent.layout.height);
        setFormMinHeight((prev) => (measured > prev ? measured : prev));
        setProbed(true);
    }, []);
    // No enter/exit animation until the measuring pass is done.
    //
    // When `probed` flips, the probe's Sign Up fields unmount — and an exiting
    // animation on that teardown plays VISIBLY, because the wrapper's opacity is
    // back to 1 by the time it runs. That was a Display Name field blinking into
    // view and fading out on first render, for a frame the user was never meant
    // to see at all.
    const t = useMemo(() => probed ? baseTransition : { ...baseTransition, entering: undefined, exiting: undefined }, [baseTransition, probed]);
    const clearField = (field) => {
        if (errors[field]) {
            setErrors((e) => ({ ...e, [field]: undefined }));
        }
    };
    const validate = () => {
        const next = {};
        if (!email) {
            next.email = "Email is required";
        }
        else if (!validateEmail(email)) {
            next.email = "Please enter a valid email";
        }
        if (!password) {
            next.password = "Password is required";
        }
        else if (password.length < 8) {
            next.password = "Password must be at least 8 characters";
        }
        if (isSignup) {
            if (!displayName) {
                next.displayName = "Display name is required";
            }
            else if (displayName.length > 100) {
                next.displayName = "Display name must be 100 characters or less";
            }
            if (!confirmPassword) {
                next.confirmPassword = "Please confirm your password";
            }
            else if (password !== confirmPassword) {
                next.confirmPassword = "Passwords do not match";
            }
            if (requireInviteCode && !inviteCode.trim()) {
                next.inviteCode = "An invite code is required to create an account here";
            }
        }
        return next;
    };
    const shakeFor = (next) => {
        if (next.email) {
            emailShake.shake();
        }
        if (next.displayName) {
            displayNameShake.shake();
        }
        if (next.password) {
            passwordShake.shake();
        }
        if (next.confirmPassword) {
            confirmShake.shake();
        }
    };
    const performLogin = async () => {
        setIsLoading(true);
        try {
            await authLogin(email.trim().toLowerCase(), password);
            setPassword("");
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
        }
        catch (error) {
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
            const message = error instanceof Error ? error.message : "Login failed";
            const code = error instanceof ApiError ? error.code : undefined;
            if (code === "INVALID_CREDENTIALS") {
                setErrors({ password: "Invalid email or password" });
                emailShake.shake();
                passwordShake.shake();
            }
            else if (code === "ACCOUNT_LOCKED") {
                setErrors({ email: "Account is locked. Please contact support." });
                emailShake.shake();
            }
            else {
                setErrors({ password: message });
                passwordShake.shake();
            }
        }
        finally {
            setIsLoading(false);
        }
    };
    const performRegister = async () => {
        setIsLoading(true);
        const policy = getCachedPolicy() ?? DEFAULT_POLICY;
        if (policy.enabled) {
            const pwned = await checkPasswordPwned(password);
            if (pwned.isPwned) {
                setPwnedInfo({ ...getPwnedMessage(pwned), count: pwned.count });
                if (policy.enforce && pwned.count > policy.block_threshold) {
                    await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
                    passwordShake.shake();
                    confirmShake.shake();
                    setIsLoading(false);
                    return;
                }
            }
            else {
                setPwnedInfo(null);
            }
        }
        try {
            if (onSignUp) {
                // The host owns the contract. It may leave the user signed out (the
                // IdP emails a verification link), so show the notice rather than
                // assuming a session now exists.
                await onSignUp({
                    email: email.trim().toLowerCase(),
                    password,
                    displayName: displayName.trim(),
                    inviteCode: requireInviteCode ? inviteCode.trim() : undefined,
                });
                setSignedUp(signUpSuccessMessage);
                setPassword("");
                setConfirmPassword("");
                setInviteCode("");
                setMode("signin");
            }
            else {
                await authRegister(email.trim().toLowerCase(), password, displayName.trim());
            }
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
        }
        catch (error) {
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
            const message = error instanceof Error ? error.message : "Registration failed";
            if (message.includes("email") && message.includes("exists")) {
                setErrors({ email: "This email is already registered" });
                emailShake.shake();
            }
            else if (message.includes("password")) {
                setErrors({ password: message });
                passwordShake.shake();
            }
            else {
                setErrors({ email: message });
                emailShake.shake();
            }
        }
        finally {
            setIsLoading(false);
        }
    };
    const handleSubmit = async () => {
        if (isLoading) {
            return;
        }
        const next = validate();
        if (Object.keys(next).length > 0) {
            setErrors(next);
            shakeFor(next);
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
            return;
        }
        setErrors({});
        if (isSignup) {
            await performRegister();
        }
        else {
            await performLogin();
        }
    };
    const routeAfterUnlock = (onboardingStep) => {
        if (onboardingStep === "email_verification") {
            router.replace({ pathname: routes.verifyEmail, params: { email } });
        }
        else if (onboardingStep === "mfa_enrollment") {
            router.replace(routes.mfaEnroll);
        }
        else if (onboardingStep === "import") {
            router.replace(routes.importData);
        }
        else {
            router.replace(routes.home);
        }
    };
    const handlePasskeyLogin = async () => {
        setPasskeyStep("passkey-loading");
        setErrors({});
        passkeyAbortRef.current?.abort();
        const controller = new AbortController();
        passkeyAbortRef.current = controller;
        try {
            const trimmed = email.trim().toLowerCase();
            const result = await loginWithPasskey(trimmed.length > 0 ? trimmed : undefined, controller.signal);
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
            if (result.unlocked) {
                setPasskeyStep("idle");
                routeAfterUnlock(result.user.onboarding_step);
                return;
            }
            setPendingOnboardingStep(result.user.onboarding_step ?? null);
            setPasskeyStep("passkey-unlock");
        }
        catch (error) {
            const name = error instanceof Error ? error.name : undefined;
            const benign = name === "NotAllowedError" || name === "AbortError";
            if (!benign) {
                await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
                setErrors({ password: error instanceof Error ? error.message : "Passkey sign-in failed" });
            }
            setPasskeyStep("idle");
        }
    };
    const handleUnlockAfterPasskey = async () => {
        if (!unlockPassword) {
            setUnlockError("Master password is required");
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
            return;
        }
        setIsLoading(true);
        try {
            await unlockWithPassword(unlockPassword);
            setUnlockPassword("");
            setUnlockError("");
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
            routeAfterUnlock(pendingOnboardingStep ?? "complete");
            setPasskeyStep("idle");
        }
        catch (error) {
            setUnlockError(error instanceof Error ? error.message : "Failed to unlock the vault. Check your password.");
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
        }
        finally {
            setIsLoading(false);
        }
    };
    const handleForgot = async () => {
        if (isLoading) {
            return;
        }
        if (!email || !validateEmail(email)) {
            setErrors({ email: "Please enter a valid email" });
            emailShake.shake();
            return;
        }
        setErrors({});
        setIsLoading(true);
        try {
            await AuthService.forgotPassword(email.trim().toLowerCase());
            setForgotSent(true);
            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
        }
        catch {
            // Don't leak account existence — always show the same confirmation.
            setForgotSent(true);
        }
        finally {
            setIsLoading(false);
        }
    };
    const goAuth = () => {
        setStage("auth");
        setForgotSent(false);
        setErrors({});
    };
    return (<>
      <SafeAreaView className="flex-1 bg-background dark:bg-black">
        <StatusBar style={isDark ? "light" : "dark"}/>
        {backdrop ? (<View pointerEvents="none" style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}>
            {backdrop}
          </View>) : null}
        <KeyboardAvoidingView 
    // undefined on Android, NOT "height".
    //
    // Android resizes the window itself under `adjustResize`, which is
    // Expo's default and what every consumer here uses. Asking
    // KeyboardAvoidingView to also subtract the keyboard height meant the
    // layout was compensated twice — the form shrank instead of moving,
    // and the fields it was supposed to reveal stayed covered.
    behavior={Platform.OS === "ios" ? "padding" : undefined} className="flex-1" 
    // Full-bleed two-pane split. The aside is a real panel with its own
    // ground colour reaching the window edge, so it cannot be centred
    // inside a capped container — a floating panel with black either
    // side would read as a mistake. The form column is fixed and the
    // aside takes everything else, which on a typical desktop leaves it
    // roughly two thirds.
    style={showAside ? { flexDirection: "row" } : undefined}>
          {showAside ? (
        // Sits outside the ScrollView on purpose: the pane is scenery and
        // should hold still while the form beside it scrolls.
        <View style={{ flex: 1 }} pointerEvents="box-none">
              {aside}
            </View>) : null}
          <ScrollView className="flex-1" 
    // Wide: a fixed column on the right rather than `flex: 1`, so the
    // form keeps a comfortable measure instead of growing with the
    // window and stranding its 360px card in the middle of a wide lane.
    style={showAside ? { flexGrow: 0, flexBasis: FORM_PANE_WIDTH } : undefined} ref={scrollRef} 
    // The extra bottom padding is what MAKES the scroll possible.
    // `flexGrow: 1` alone sizes the content to the viewport exactly, so
    // scrollToEnd has nowhere to go; this guarantees a scrollable
    // overhang while the keyboard is up and disappears when it goes.
    contentContainerStyle={keyboardVisible
            ? [SCROLL_CONTENT_STYLE, KEYBOARD_SCROLL_PADDING]
            : SCROLL_CONTENT_STYLE} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
            <View className="flex-1 justify-between px-6" 
    // Asymmetric on the wide layout: more room against the window
    // edge than against the panel seam, so the card sits inside its
    // column rather than pressed to the right of the screen.
    // Symmetric: see FORM_PANE_WIDTH. Asymmetric padding would shift the card
    // off centre and break the matching gaps.
    style={showAside ? { paddingLeft: 64, paddingRight: 64 } : undefined}>
              {/* top bar: settings gear only — back lives inside the cards
            (thumb-reachable) so users don't stretch to the corner. */}
              <View className="mt-4 h-10 flex-row items-center justify-between">
                <View />
                {settings || onSettingsPress ? (<IconButton variant="ghost" size="sm" telemetryId="auth.auth.settings_open" onPress={() => {
                if (onSettingsPress) {
                    onSettingsPress();
                    return;
                }
                setSettingsOpen(true);
            }} accessibilityLabel="Connection settings" accessibilityRole="button">
                    <Ionicons name="settings-outline" size={22} color={isDark ? "#A3A3A3" : "#737373"}/>
                  </IconButton>) : (<View />)}
              </View>

              {/* One morph view driven by `morph`: dematerialize → swap
            layout+content at zero opacity → materialize. Welcome is
            centered; the form is top-anchored. Because the swap happens
            while invisible, the position change is never seen (no
            teleport) and the two stages never overlap. */}
              <Animated.View style={[
            { flex: 1, width: "100%", alignItems: "center", justifyContent: "center" },
            morphStyle,
        ]}>
                {/* On the wide layout the lockup is constrained to the same
            360px column as the title and the card and left-aligned with
            them, so all three share one left edge. Centred, it sat at
            its own width and read as indented against the form below
            it. Narrow keeps it centred, where there is no column to
            align to. */}
                <View className={showAside ? "mb-6 w-full max-w-[360px] items-start" : "mb-6 items-center"}>
                  {logoIsWordmark ? (
        // The mark carries the name; height is left to the
        // component so a wide wordmark keeps its aspect ratio, and
        // the ink colour comes from the already-resolved theme.
        <Logo width={logoWidth ?? 160} color={isDark ? "#FFFFFF" : "#000000"}/>) : lockupBrand ? (
        // Lockup: mark, vertical rule, then the name over the
        // tagline. The mark and the rule are both sized to the
        // MEASURED height of that text block, so all three read as
        // one unit at whatever type scale the app (or the system
        // font size) ends up at. `brandTextHeight || 64` only
        // covers the single frame before the first measurement.
        <View className="flex-row items-center" style={{ gap: 14 }}>
                      <Logo width={brandTextHeight || 64} height={brandTextHeight || 64} bleed/>
                      <View style={{ width: 1, height: brandTextHeight || 64 }} className="bg-border dark:bg-neutral-700"/>
                      <View onLayout={onBrandTextLayout}>
                        <Heading className="text-4xl">{appName}</Heading>
                        {tagline ? (<MutedText className="font-mono text-base">{tagline}</MutedText>) : null}
                      </View>
                    </View>) : (<View className="flex-row items-center">
                      <Logo width={72} height={72} style={{ marginRight: -2 }}/>
                      <Heading className="text-4xl">{appName}</Heading>
                      {/* Inline tagline sits on the name's baseline, at body
                size — matching the 4xl heading would make the
                category label compete with the brand. */}
                      {tagline && inlineTagline ? (<MutedText className="ml-2 font-mono text-base">{`| ${tagline}`}</MutedText>) : null}
                    </View>)}
                  {tagline && !inlineTagline && !lockupBrand ? (<View className="items-center">
                      {/* The rule is only drawn for the "ruled" layout. Width
                tracks the row rather than being a fixed px value, so
                it lines up under the logo + name pairing at whatever
                size the app renders them. */}
                      {ruledTagline ? (<View className="mt-3 h-px w-full self-stretch bg-border dark:bg-neutral-800"/>) : null}
                      <MutedText className={ruledTagline ? "mt-3 font-mono text-base" : "mt-1 font-mono text-base"}>
                        {tagline}
                      </MutedText>
                    </View>) : null}
                </View>

                {onWelcome ? (<View style={{ width: "100%", alignItems: "center" }}>
                    {decoration ? (<View className="mb-6 w-full items-center">{decoration}</View>) : null}
                    <View style={{ width: "100%", maxWidth: 320 }}>
                      <Button className="w-full" telemetryId="auth.auth.stage" onPress={() => {
                setStage("auth");
                onWelcomeDismissed?.();
            }} accessibilityLabel={welcomeCtaLabel} accessibilityRole="button" testID="welcome-get-started">
                        {welcomeCtaLabel}
                      </Button>
                    </View>
                  </View>) : (<View style={{ width: "100%", alignItems: "center" }}>
                    {/* Positioned wrapper so the ambient layer can sit behind
                the card and bleed past its edges. Width tracks the
                card's own max so the glow is centred on the form, not
                the viewport. */}
                    {/* No mode heading above the card. The Sign In / Sign Up
                toggle at the top of the card already says which mode
                this is, and a title restating it directly beneath the
                brand lockup made three stacked pieces of naming before
                the first field. The forgot-password card keeps its own
                header title, which is doing real work. */}
                    <View style={{ width: "100%", maxWidth: 360, position: "relative" }}>
                      {cardBackdrop && !showAside ? (<View pointerEvents="none" style={{
                    position: "absolute",
                    top: -cardBackdropBleed,
                    left: -cardBackdropBleed,
                    right: -cardBackdropBleed,
                    bottom: -cardBackdropBleed,
                }}>
                          {cardBackdrop}
                        </View>) : null}
                      {/* ── WHOSE login this is, ABOVE the card ──────────────
              Without it the card is a pair of unlabelled fields under
              a Sign In / Sign Up toggle: fine when the user arrived
              from the app they are signing into, actively confusing
              when an OIDC redirect dropped them here from somewhere
              else -- the case an identity provider is FOR.

              Above the card rather than in its header, which is where
              this started. In the header it sat directly over the
              mode toggle and read as a label for the selected mode
              ("Log in to Entropy" above a Sign In / Sign Up switch
              restates the switch). Out here it is unambiguously the
              page's own heading, the card below it is purely the
              form, and the brand backdrop has something to sit
              behind.

              Width is pinned to the card's 360px so the two are one
              column, and the text is centred to match. */}
                      {title ? (<View className="w-full max-w-[360px] gap-1 pb-5">
                          <Heading className="text-center text-xl">{title}</Heading>
                          {subtitle ? (<MutedText className="text-center text-sm leading-5">
                              {subtitle}
                            </MutedText>) : null}
                        </View>) : null}
                      <Card className="w-full max-w-[360px]">
                        {displayStage === "forgot" ? (<>
                            <CardHeader>
                              <CardTitle>Reset password</CardTitle>
                            </CardHeader>
                            <CardContent className="gap-4">
                              {forgotSent ? (<MutedText className="text-sm leading-5">
                                  If an account exists for that email, we&apos;ve sent a link to
                                  reset your password. Check your inbox.
                                </MutedText>) : (<>
                                  <MutedText className="text-sm leading-5">
                                    Enter your email and we&apos;ll send you a link to reset your
                                    password.
                                  </MutedText>
                                  <ShakeField shake={emailShake} label="Email">
                                    <EmailInput value={email} onChangeText={(text) => {
                        setEmail(text);
                        clearField("email");
                    }} autoFocus placeholder="you@example.com" autoComplete="email" invalid={!!errors.email} returnKeyType="done" onSubmitEditing={handleForgot} accessibilityLabel="Email address"/>
                                  </ShakeField>
                                  <FormError message={errors.email}/>
                                </>)}
                            </CardContent>
                            <CardFooter className="flex-col items-stretch gap-3 border-t-0 pt-0">
                              {forgotSent ? (<Button className="w-full" telemetryId="auth.auth.go_auth" onPress={goAuth} accessibilityLabel="Back to sign in">
                                  Back to Sign In
                                </Button>) : (<>
                                  <Button className="w-full" telemetryId="auth.auth.forgot" onPress={handleForgot} isLoading={isLoading} accessibilityLabel="Send reset link">
                                    Send reset link
                                  </Button>
                                  <MutedText className="mt-1 text-center font-mono text-sm" onPress={withClickTracking("auth.auth.go_auth_2", goAuth)} accessibilityRole="button" accessibilityLabel="Back to sign in">
                                    ← Back
                                  </MutedText>
                                </>)}
                            </CardFooter>
                          </>) : (<>
                            {/* `gap-5` under the toggle, not `gap-3`. The
                  toggle is the mode switch and the title restates
                  the mode, so they were reading as one crowded
                  block; a wider gap separates the control from the
                  heading it drives and takes some of the slack
                  that otherwise all collects above the footer.
                  Applies to both modes — the header is shared. */}
                            <CardHeader className="gap-5 pb-3 pt-5">
                              {/* The heading moved OUT of the card -- see the
                  block above <Card>. Inside here it read as a
                  label for whichever toggle mode was selected;
                  above the card it reads as the page saying whose
                  login this is, which is what it is for. */}
                              {signUpEnabled ? (<SegmentedToggle value={mode} onChange={switchMode} options={[
                        { key: "signin", label: "Sign In" },
                        { key: "signup", label: "Sign Up" },
                    ]}/>) : null}
                              {signedUp ? (<MutedText className="text-sm leading-5">{signedUp}</MutedText>) : null}
                            </CardHeader>

                            {/* Height is locked to the tallest of the two
                  modes (see the form-box note above), so the
                  layout transition only ever eases fields in and
                  out — the card itself no longer moves. Width is
                  pinned explicitly so the layout animation has
                  nothing to interpolate horizontally either. */}
                            <Animated.View layout={t.layout} onLayout={onFormLayout} pointerEvents={probeSignup ? "none" : "auto"} style={{
                    width: "100%",
                    minHeight: lockFormHeight ? formMinHeight || undefined : undefined,
                    opacity: probeSignup ? 0 : 1,
                }}>
                              {/* `flex: 1` with the default top alignment, so the
                  fields keep their natural rhythm and ALL the
                  locked height's slack collects in one place —
                  between the last field and the footer.
                  That pins the submit button, the OR divider and
                  the social row to the same y in both modes, so
                  they hold still while toggling instead of
                  jumping up and down by the height of two
                  fields.
                  Note this is not the earlier attempt: spreading
                  the slack BETWEEN the rows (`space-between`)
                  looked worse than the gap it removed. Same
                  total space, collected instead of distributed. */}
                              <CardContent className="gap-2.5 pb-2" style={{ flex: 1 }}>
                                {!showSignup && passkeyEnabled ? (<Animated.View entering={t.entering} exiting={t.exiting} layout={t.layout}>
                                    <Button variant="outline" className="w-full" telemetryId="auth.auth.passkey_login" onPress={handlePasskeyLogin} isLoading={passkeyStep === "passkey-loading"} accessibilityLabel="Sign in with passkey" accessibilityRole="button" testID="login-passkey-button">
                                      <View className="flex-row items-center justify-center">
                                        <Ionicons name="key-outline" size={20} color={isDark ? "#FFFFFF" : "#000000"} className="mr-2"/>
                                        <Text className="text-base font-medium">
                                          Sign in with passkey
                                        </Text>
                                      </View>
                                    </Button>
                                    <View className="mt-3">
                                      <SeparatorWithText />
                                    </View>
                                  </Animated.View>) : null}

                                {/* No autoFocus on this one. Arriving here
                    popped the keyboard and put the cursor in
                    Email, which presumes the visitor came to
                    sign in — and the Sign up half of the toggle
                    directly above it is the other half of that
                    question. Let them answer it. The
                    forgot-password panel keeps its autoFocus:
                    that is a single field somebody opened on
                    purpose. */}
                                <ShakeField shake={emailShake} label="Email">
                                  <EmailInput value={email} onChangeText={(text) => {
                    setEmail(text);
                    clearField("email");
                }} placeholder="you@example.com" autoComplete="email" invalid={!!errors.email} accessibilityLabel="Email address" testID="auth-email-input"/>
                                </ShakeField>

                                {showSignup ? (<Animated.View key="displayName" entering={t.entering} exiting={t.exiting} layout={t.layout}>
                                    <ShakeField shake={displayNameShake} label="Display Name">
                                      <Input value={displayName} onChangeText={(text) => {
                        setDisplayName(text);
                        clearField("displayName");
                    }} placeholder="Your display name" leftIcon={<Ionicons name="person-outline" size={20} color={isDark ? "#A3A3A3" : "#737373"}/>} autoCapitalize="words" autoCorrect={false} maxLength={100} invalid={!!errors.displayName} accessibilityLabel="Display name" testID="auth-displayname-input"/>
                                    </ShakeField>
                                  </Animated.View>) : null}

                                <ShakeField shake={passwordShake} label={passwordLabel}>
                                  <PasswordInput value={password} onChangeText={(text) => {
                    setPassword(text);
                    clearField("password");
                    if (isSignup) {
                        checkPasswordDebounced(text);
                    }
                }} placeholder={passwordPlaceholder} autoComplete={isSignup ? "password-new" : "password"} invalid={!!errors.password} returnKeyType={isSignup ? "next" : "done"} onSubmitEditing={isSignup ? undefined : handleSubmit} accessibilityLabel={passwordLabel} testID="auth-password-input"/>
                                </ShakeField>

                                {/* Rendered only when it has something to show.
                  An empty wrapper still consumes one of
                  CardContent's `gap-3` slots, which put 12px
                  more space above Confirm Password than
                  between any other pair of fields — visible as
                  a stray gap. `probeSignup` keeps it in the
                  measuring pass so the locked height still
                  accounts for the meter. */}
                                {showSignup && (probeSignup || password.length > 0 || pwnedInfo) ? (<Animated.View key="strength" entering={t.entering} exiting={t.exiting} layout={t.layout}>
                                    {/* `reserveSpace` only during the probe.
                      Keeping it on permanently held an empty
                      28px block between Password and Confirm
                      Password, which reads as a stray gap.
                      Reserving it just for the measurement
                      means the locked height still accounts
                      for the meter, so it can appear later
                      without the card growing. */}
                                    <PasswordStrengthMeter password={password} reserveSpace={probeSignup}/>
                                    <PwnedPasswordWarning pwnedInfo={pwnedInfo}/>
                                  </Animated.View>) : null}

                                {showSignup ? (<Animated.View key="confirm" entering={t.entering} exiting={t.exiting} layout={t.layout}>
                                    <ShakeField shake={confirmShake} label="Confirm Password">
                                      <PasswordInput value={confirmPassword} onChangeText={(text) => {
                        setConfirmPassword(text);
                        clearField("confirmPassword");
                    }} placeholder="Re-enter your password" autoComplete="password-new" returnKeyType="done" onSubmitEditing={handleSubmit} invalid={!!errors.confirmPassword} accessibilityLabel="Confirm password" testID="auth-confirm-password-input"/>
                                    </ShakeField>
                                    {requireInviteCode ? (<View className="mt-3">
                                        <ShakeField shake={confirmShake} label="Invite code">
                                          <Input value={inviteCode} onChangeText={(text) => {
                            setInviteCode(text);
                            clearField("inviteCode");
                        }} placeholder="Paste your invite code" autoCapitalize="none" autoCorrect={false} returnKeyType="done" onSubmitEditing={handleSubmit} invalid={!!errors.inviteCode} accessibilityLabel="Invite code" testID="auth-invite-code-input"/>
                                        </ShakeField>
                                        <FormError message={errors.inviteCode}/>
                                      </View>) : null}
                                  </Animated.View>) : !forgotPasswordEnabled ? null : (<Animated.View key="forgot" entering={t.entering} exiting={t.exiting} layout={t.layout} className="items-end">
                                    <Text className="font-mono text-sm underline" onPress={withClickTracking("auth.auth.stage_2", () => {
                        setStage("forgot");
                        setForgotSent(false);
                        setErrors({});
                    })} accessibilityRole="link" accessibilityLabel="Forgot password">
                                      Forgot password?
                                    </Text>
                                  </Animated.View>)}
                              </CardContent>

                              <CardFooter className="flex-col items-stretch gap-2.5 border-t-0 pb-5 pt-0">
                                <FormError message={firstError}/>

                                <Button className="w-full" telemetryId="auth.auth.submit" onPress={handleSubmit} isLoading={isLoading} accessibilityLabel={isSignup ? "Create account" : "Sign in"} accessibilityRole="button" testID="auth-submit">
                                  {isSignup ? "Create Account" : "Sign In"}
                                </Button>

                                {socialAuth ? (<>
                                    <SeparatorWithText />
                                    {socialAuth}
                                  </>) : null}

                                {/* In-card back — thumb-reachable, so users don't
                have to stretch to the top-left corner arrow.
                Hidden when there is no welcome stage behind it. */}
                                <Text className={showWelcome
                    ? "mt-1 text-center font-mono text-sm text-muted-foreground"
                    : "hidden"} onPress={withClickTracking("auth.auth.stage_3", () => setStage("welcome"))} accessibilityRole="button" accessibilityLabel="Back">
                                  ← Back
                                </Text>
                              </CardFooter>
                            </Animated.View>
                          </>)}
                      </Card>
                    </View>
                  </View>)}
              </Animated.View>

              {/* Legal footer — Terms/Privacy as popups, no routes.
            Welcome stage ONLY. Pressing "Get Started" and proceeding IS
            the acknowledgement, so the line belongs beside that button
            and nowhere else; repeating it under the sign-in and sign-up
            forms restates a consent the user already gave and crowds two
            screens that are busier than the welcome. When there is no
            welcome stage at all (`showWelcome={false}`) it stays on the
            form. Note there is deliberately NO "but show it on the form
            if there is no welcome stage" fallback: `showWelcome` is false
            on every launch after the first, so such a fallback puts the
            line back under the form permanently — which is exactly the
            bug it was written to avoid. */}
              <View className={onWelcome ? "mb-6 mt-4" : "hidden"}>
                <MutedText className="text-center text-xs leading-5">
                  By continuing, you agree to our{" "}
                  <Text className="text-xs text-foreground underline" onPress={withClickTracking("auth.auth.legal", () => setLegal("terms"))} accessibilityRole="link" accessibilityLabel="Terms of Service">
                    Terms of Service
                  </Text>{" "}
                  and{" "}
                  <Text className="text-xs text-foreground underline" onPress={withClickTracking("auth.auth.legal_2", () => setLegal("privacy"))} accessibilityRole="link" accessibilityLabel="Privacy Policy">
                    Privacy Policy
                  </Text>
                </MutedText>
              </View>

              {footer && !showAside ? <View className="mb-6">{footer}</View> : null}
            </View>
          </ScrollView>
        </KeyboardAvoidingView>
        {/* Wide only. Screen-level and absolutely placed, so it centres on the
            PAGE rather than under the form column, and drawn after the panes so
            it sits above them. `pointerEvents="box-none"` keeps it from
            intercepting a tap meant for anything behind it.
            On a narrow layout it is rendered IN FLOW instead (below): the card
            reaches the bottom of a phone screen, and an absolute footer simply
            lands on top of the card's own back link. */}
        {footer && showAside ? (<View pointerEvents="box-none" style={{ position: "absolute", left: 0, right: 0, bottom: 16, alignItems: "center" }}>
            {footer}
          </View>) : null}
      </SafeAreaView>

      {/* Passkey unlock dialog — non-PRF authenticators. */}
      <Dialog visible={passkeyStep === "passkey-unlock"} onClose={() => {
            setPasskeyStep("idle");
            setUnlockPassword("");
            setUnlockError("");
        }} draggable dismissOnBackdrop={false}>
        <View className="mb-5 items-center">
          <View className={`mb-4 h-14 w-14 items-center justify-center rounded-full ${isDark ? "bg-white/10" : "bg-black/5"}`}>
            <Ionicons name="lock-open-outline" size={28} color={isDark ? "#FFFFFF" : "#000000"}/>
          </View>
          <Heading className="mb-2 text-xl">{passkeyUnlockTitle}</Heading>
          <Text className="mb-3 text-center text-sm leading-5">{passkeyUnlockDescription}</Text>
          <PasswordInput value={unlockPassword} onChangeText={(text) => {
            setUnlockPassword(text);
            if (unlockError) {
                setUnlockError("");
            }
        }} placeholder={passkeyUnlockPlaceholder} error={unlockError} autoComplete="password" returnKeyType="done" onSubmitEditing={handleUnlockAfterPasskey} accessibilityLabel="Master password" testID="login-passkey-unlock-input"/>
        </View>
        <DialogFooter style={{ justifyContent: "center" }}>
          <DialogButton variant="primary" onPress={withClickTracking("auth.auth.action", isLoading ? () => { } : handleUnlockAfterPasskey)} accessibilityLabel="Unlock" accessibilityRole="button">
            {isLoading ? "Unlocking…" : "Unlock"}
          </DialogButton>
        </DialogFooter>
      </Dialog>

      {/* Folded-in connection/server settings dialog. */}
      {settings?.({ visible: settingsOpen, onClose: () => setSettingsOpen(false) })}

      {/* Terms / Privacy popups. */}
      <LegalDialog visible={legal === "terms"} onClose={() => setLegal(null)} title="Terms of Service" content={termsText}/>
      <LegalDialog visible={legal === "privacy"} onClose={() => setLegal(null)} title="Privacy Policy" content={privacyText}/>
    </>);
}
export default AuthScreen;
//# sourceMappingURL=AuthScreen.jsx.map