import Combine
import SwiftUI

/// The signed-out experience: the website's airy-sky backdrop (painterly cloud
/// images, breathing gradient, sun glow) with a full-bleed bottom sheet that
/// docks flush to the screen edge and rides the keyboard. Steps mirror the web
/// (email -> password -> emailed 6-digit code -> optional TOTP) plus native
/// Sign in with Apple / Google. Registration confirm returns no tokens, so it
/// chains back into the login flow.
struct AuthFlowView: View {
    private enum Step: Equatable {
        case email
        case password
        case verify(session: String)
        case twoFA(pendingToken: String)
        case resetSent
    }

    private enum Mode {
        case signIn, signUp
    }

    @Environment(AppEnvironment.self) private var env

    @State private var step = Step.email
    @State private var direction = 1.0
    @State private var mode = Mode.signIn
    @State private var email = ""
    @State private var password = ""
    @State private var code = ""
    @State private var recoveryCode = ""
    @State private var useRecovery = false
    @State private var registerFlow = false
    @State private var busy = false
    @State private var errorMessage: String?
    @State private var errorPulse = 0
    @State private var infoMessage: String?
    @State private var resendRemaining = 0
    @State private var providers: AuthProvidersInfo?
    @State private var showServerConfig = false
    @State private var heroAppeared = false
    @State private var modeSwitchEdge = Edge.trailing

    @FocusState private var focusedField: Field?

    private enum Field { case email, password, recovery }

    /// The email step keeps the big cloud art; deeper steps clear the sky
    /// for the step badge.
    private var heroExpanded: Bool { step == .email }

    var body: some View {
        ZStack(alignment: .bottom) {
            SkyBackdrop(largeCloud: heroExpanded)
            stepScreen
        }
        .sensoryFeedback(.impact(weight: .light), trigger: step)
        .sensoryFeedback(.error, trigger: errorPulse)
        .sheet(isPresented: $showServerConfig) { ServerConfigSheet() }
        .task { providers = await env.session.fetchAuthProviders() }
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
            if resendRemaining > 0 {
                withAnimation(.easeOut(duration: 0.3)) { resendRemaining -= 1 }
            }
        }
    }

    private var stepTransition: AnyTransition {
        .asymmetric(
            insertion: .move(edge: direction > 0 ? .trailing : .leading).combined(with: .opacity),
            removal: .move(edge: direction > 0 ? .leading : .trailing).combined(with: .opacity)
        )
    }

    private func go(to next: Step, direction dir: Double = 1) {
        errorMessage = nil
        direction = dir
        withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) {
            step = next
        }
    }

    // MARK: - Screen: sky hero + full-bleed bottom sheet

    private var stepScreen: some View {
        VStack(spacing: 0) {
            HStack(spacing: 12) {
                if step != .email {
                    skyIconButton("chevron.left", label: "Back") { goBackOneStep() }
                        .transition(.opacity.combined(with: .scale(scale: 0.7)))
                }
                compactLockup
                Spacer()
                skyIconButton("gearshape", label: "Server settings") { showServerConfig = true }
            }
            .padding(.horizontal, 16)
            .padding(.top, 6)
            // The top bar reflow animates here; the sheet is left alone so it
            // tracks the keyboard's own curve with no gap.
            .animation(.spring(response: 0.45, dampingFraction: 0.86), value: step == .email)

            heroArea
                .animation(.spring(response: 0.5, dampingFraction: 0.85), value: heroExpanded)

            bottomSheet
        }
    }

    /// The brand lockup, permanently docked in the top-left: the sky's center
    /// stays free for the feature showcase.
    private var compactLockup: some View {
        HStack(spacing: 8) {
            WarmblyLogo()
                .fill(.white)
                .frame(width: 27, height: 28)
            Text("Warmbly")
                .font(.system(size: 20, weight: .heavy))
                .tracking(-0.4)
                .foregroundStyle(.white)
                .fixedSize()
        }
        .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3)
    }

    /// The sky between the top bar and the sheet. On the email step it shows
    /// the richest thing the available height can hold: the showcase at full
    /// size, the showcase gently scaled down (to ~72%) when the space is only
    /// a little tight (error line, small screens), the one-line ticker when
    /// the keyboard truly eats the room, plain sky as a last resort. Deeper
    /// steps show the step badge. Always a tap-to-dismiss surface, and the
    /// ambience never stops.
    private var heroArea: some View {
        ZStack {
            Color.clear
                .contentShape(Rectangle())
                .onTapGesture { focusedField = nil }

            HeroFlightScene()

            if step == .email {
                GeometryReader { proxy in
                    let available = proxy.size.height
                    ZStack {
                        if available >= FeatureShowcase.idealHeight * 0.72 {
                            FeatureShowcase()
                                .scaleEffect(min(1, available / FeatureShowcase.idealHeight))
                        } else if available >= 64 {
                            FeatureTicker()
                        }
                    }
                    .frame(width: proxy.size.width, height: available)
                }
                .scaleEffect(heroAppeared ? 1 : 0.92)
                .opacity(heroAppeared ? 1 : 0)
                .transition(.opacity.combined(with: .scale(scale: 0.94)))
            } else if let progress = stepProgress {
                stepIndicator(progress)
                    .transition(.opacity.combined(with: .scale(scale: 0.9)))
            }
        }
        .frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity)
        .onAppear {
            withAnimation(.spring(response: 0.7, dampingFraction: 0.75).delay(0.05)) {
                heroAppeared = true
            }
        }
    }

    /// Where the user is in the flow, shown in the freed-up sky on the
    /// deeper steps: the step's icon, name, position, and segment bar.
    private var stepProgress: (index: Int, icon: String, label: String)? {
        switch step {
        case .email, .resetSent:
            return nil
        case .password:
            return (1, "lock.fill", mode == .signIn ? "Your password" : "Choose a password")
        case .verify:
            return (2, "envelope.open.fill", "Check your email")
        case .twoFA:
            return (2, "shield.lefthalf.filled", "Two-factor check")
        }
    }

    /// Picks the tallest step badge that fits the sky left over above the
    /// keyboard: icon bubble + name + segments, or a single compact row.
    private func stepIndicator(_ progress: (index: Int, icon: String, label: String)) -> some View {
        ViewThatFits(in: .vertical) {
            VStack(spacing: 12) {
                ZStack {
                    Circle()
                        .fill(.white.opacity(0.16))
                    Circle()
                        .strokeBorder(.white.opacity(0.3), lineWidth: 1)
                    Image(systemName: progress.icon)
                        .font(.system(size: 20, weight: .semibold))
                        .foregroundStyle(.white)
                        .contentTransition(.symbolEffect(.replace))
                }
                .frame(width: 54, height: 54)

                VStack(spacing: 4) {
                    Text(progress.label)
                        .font(.system(size: 15.5, weight: .bold))
                        .foregroundStyle(.white)
                    Text("Step \(progress.index + 1) of 3")
                        .font(.system(size: 12))
                        .foregroundStyle(.white.opacity(0.7))
                }

                stepSegments(progress.index)
            }
            .padding(.vertical, 16)

            HStack(spacing: 10) {
                ZStack {
                    Circle().fill(.white.opacity(0.16))
                    Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1)
                    Image(systemName: progress.icon)
                        .font(.system(size: 13, weight: .semibold))
                        .foregroundStyle(.white)
                        .contentTransition(.symbolEffect(.replace))
                }
                .frame(width: 34, height: 34)

                VStack(alignment: .leading, spacing: 4) {
                    Text(progress.label)
                        .font(.system(size: 13.5, weight: .semibold))
                        .foregroundStyle(.white)
                    HStack(spacing: 7) {
                        stepSegments(progress.index)
                        Text("Step \(progress.index + 1) of 3")
                            .font(.system(size: 11))
                            .foregroundStyle(.white.opacity(0.7))
                    }
                }
            }
            .padding(.vertical, 8)
        }
        .shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2)
        .animation(.spring(response: 0.4, dampingFraction: 0.85), value: progress.index)
    }

    private func stepSegments(_ active: Int) -> some View {
        HStack(spacing: 6) {
            ForEach(0 ..< 3, id: \.self) { index in
                Capsule()
                    .fill(.white.opacity(index <= active ? 0.95 : 0.3))
                    .frame(width: index == active ? 26 : 14, height: 4)
            }
        }
    }

    /// The white area: full width, flush to the bottom edge (the background
    /// shape extends under the home indicator and the keyboard), rounded only
    /// at the top. Content resizes with a spring as steps swap.
    private var bottomSheet: some View {
        VStack(alignment: .leading, spacing: 0) {
            Group {
                switch step {
                case .email:
                    emailStep
                case .password:
                    passwordStep
                case .verify:
                    verifyStep
                case .twoFA:
                    twoFAStep
                case .resetSent:
                    resetSentStep
                }
            }
            .transition(stepTransition)

            if infoMessage != nil || errorMessage != nil {
                Group {
                    if let infoMessage {
                        Text(infoMessage)
                            .foregroundStyle(WTheme.positive)
                    }
                    if let errorMessage {
                        Text(errorMessage)
                            .foregroundStyle(WTheme.negative)
                    }
                }
                .font(.system(size: 13.5))
                .frame(maxWidth: .infinity)
                .multilineTextAlignment(.center)
                .padding(.top, 14)
                .transition(.opacity)
            }
        }
        .padding(.horizontal, 24)
        .padding(.top, 30)
        .padding(.bottom, 14)
        .frame(maxWidth: .infinity, alignment: .leading)
        .contentShape(Rectangle())
        .onTapGesture { focusedField = nil }
        .background {
            // The extra tail below keeps the sheet sealed to the screen edge
            // even mid-animation, when the sheet overshoots above the rising
            // keyboard for a few frames.
            UnevenRoundedRectangle(cornerRadii: .init(topLeading: 36, topTrailing: 36))
                .fill(Color(.systemBackground)
                    .shadow(.drop(color: Color(hex: 0x0F172A).opacity(0.28), radius: 34, y: -6)))
                .padding(.bottom, -600)
                .ignoresSafeArea()
        }
        .geometryGroup()
    }

    private func skyIconButton(_ systemName: String, label: String, action: @escaping () -> Void) -> some View {
        Button(action: action) {
            Image(systemName: systemName)
                .font(.system(size: 15, weight: .semibold))
                .foregroundStyle(.white)
                .frame(width: 40, height: 40)
                .background(.white.opacity(0.16), in: Circle())
        }
        .buttonStyle(PressableButtonStyle())
        .accessibilityLabel(label)
    }

    private func goBackOneStep() {
        switch step {
        case .password, .resetSent:
            go(to: .email, direction: -1)
        case .verify:
            code = ""
            infoMessage = nil
            go(to: .password, direction: -1)
        case .twoFA:
            restart()
        default:
            break
        }
    }

    // MARK: - Shared sheet pieces

    private func sheetTitle(_ title: String, _ subtitle: String) -> some View {
        VStack(alignment: .leading, spacing: 7) {
            Text(title)
                .font(.system(size: 30, weight: .bold))
                .tracking(-0.6)
                .foregroundStyle(.primary)
            Text(subtitle)
                .font(.system(size: 15.5))
                .foregroundStyle(.secondary)
                .fixedSize(horizontal: false, vertical: true)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.bottom, 22)
    }

    private func primaryButton(_ title: String, disabled: Bool, action: @escaping () async -> Void) -> some View {
        Button {
            Task { await action() }
        } label: {
            Group {
                if busy {
                    ProgressView().tint(.white)
                } else {
                    Text(title)
                }
            }
            .font(.system(size: 17, weight: .semibold))
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .frame(height: 56)
            .background(
                LinearGradient(
                    colors: disabled && !busy
                        ? [WTheme.accent.opacity(0.35), WTheme.accent.opacity(0.35)]
                        : [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)],
                    startPoint: .top,
                    endPoint: .bottom
                ),
                in: RoundedRectangle(cornerRadius: 17)
            )
            .shadow(color: disabled ? .clear : Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6)
        }
        .buttonStyle(PressableButtonStyle())
        .disabled(busy || disabled)
        .animation(.easeOut(duration: 0.18), value: disabled)
    }

    private func authField(_ content: some View, focused: Bool) -> some View {
        content
            .font(.system(size: 16.5))
            .padding(.horizontal, 16)
            .frame(height: 56)
            .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17))
            .overlay(
                RoundedRectangle(cornerRadius: 17)
                    .strokeBorder(focused ? WTheme.accent : .clear, lineWidth: 1.8)
            )
            .animation(.easeOut(duration: 0.18), value: focused)
    }

    /// The address entered earlier, as a tappable chip that goes back a step.
    private var emailPill: some View {
        Button {
            go(to: .email, direction: -1)
        } label: {
            HStack(spacing: 7) {
                Image(systemName: "envelope")
                    .font(.system(size: 11))
                    .foregroundStyle(.tertiary)
                Text(email)
                    .lineLimit(1)
                    .truncationMode(.middle)
                Image(systemName: "pencil")
                    .font(.system(size: 10.5))
                    .foregroundStyle(.tertiary)
            }
            .font(.system(size: 14))
            .foregroundStyle(.secondary)
            .padding(.horizontal, 14)
            .padding(.vertical, 9)
            .background(Color(.secondarySystemBackground), in: Capsule())
        }
        .buttonStyle(PressableButtonStyle())
    }

    // MARK: - Step: email (social-first)

    /// Sign-in and create-account are two pages of the same sheet: toggling
    /// pushes the whole step content sideways like a native page turn.
    private var emailStep: some View {
        Group {
            emailStepContent
        }
        .id(mode)
        .transition(.push(from: modeSwitchEdge))
        .sensoryFeedback(.impact(weight: .light), trigger: mode == .signIn)
    }

    /// Shown when the app signed the user out because the session expired or
    /// was revoked, so landing back here never feels like a glitch.
    private var sessionExpiredNotice: some View {
        HStack(spacing: 8) {
            Image(systemName: "clock.badge.exclamationmark")
                .font(.system(size: 13, weight: .semibold))
                .foregroundStyle(Tone.amber.color)
            Text("You were signed out because your session expired. Sign in to continue.")
                .font(.footnote)
                .foregroundStyle(.secondary)
                .fixedSize(horizontal: false, vertical: true)
        }
        .padding(.horizontal, 12)
        .padding(.vertical, 10)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Tone.amber.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
        .padding(.bottom, 14)
    }

    private var emailStepContent: some View {
        VStack(alignment: .leading, spacing: 0) {
            if env.session.wasSignedOutBySession, mode == .signIn {
                sessionExpiredNotice
            }
            sheetTitle(
                mode == .signIn ? "Welcome back" : "Create your account",
                mode == .signIn ? "Sign in to pick up where you left off." : "Free to start. No credit card required."
            )

            // Social options collapse while typing so the field and button
            // stay right above the keyboard.
            if focusedField != .email {
                VStack(spacing: 12) {
                    SocialSignInRow(
                        providers: providers,
                        busy: $busy,
                        onError: { message in showError(message) },
                        onTwoFA: { pendingToken in
                            code = ""
                            go(to: .twoFA(pendingToken: pendingToken))
                        }
                    )

                    HStack(spacing: 12) {
                        Rectangle().fill(Color(.separator).opacity(0.45)).frame(height: 1)
                        Text("or")
                            .font(.system(size: 13.5))
                            .foregroundStyle(.tertiary)
                            .fixedSize()
                        Rectangle().fill(Color(.separator).opacity(0.45)).frame(height: 1)
                    }
                    .padding(.vertical, 4)
                }
                .padding(.bottom, 12)
                .transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .top)))
            }

            VStack(spacing: 12) {
                authField(
                    TextField("name@company.com", text: $email)
                        .textContentType(.emailAddress)
                        .keyboardType(.emailAddress)
                        .textInputAutocapitalization(.never)
                        .autocorrectionDisabled()
                        .focused($focusedField, equals: .email)
                        .submitLabel(.continue)
                        .onSubmit { continueFromEmail() },
                    focused: focusedField == .email
                )

                primaryButton("Continue with email", disabled: !isPlausibleEmail(email)) {
                    continueFromEmail()
                }
            }

            HStack(spacing: 5) {
                Text(mode == .signIn ? "New to Warmbly?" : "Already have an account?")
                    .foregroundStyle(.secondary)
                Button(mode == .signIn ? "Create an account" : "Sign in") {
                    modeSwitchEdge = mode == .signIn ? .trailing : .leading
                    withAnimation(.spring(response: 0.45, dampingFraction: 0.88)) {
                        mode = mode == .signIn ? .signUp : .signIn
                    }
                }
                .foregroundStyle(WTheme.accent)
                .fontWeight(.semibold)
            }
            .font(.system(size: 14.5))
            .frame(maxWidth: .infinity)
            .padding(.top, 18)
        }
        .animation(.spring(response: 0.4, dampingFraction: 0.85), value: focusedField)
    }

    private func continueFromEmail() {
        guard isPlausibleEmail(email) else { return }
        password = ""
        go(to: .password)
    }

    // MARK: - Step: password

    private var passwordStep: some View {
        VStack(alignment: .leading, spacing: 0) {
            sheetTitle(
                mode == .signIn ? "Enter your password" : "Choose a password",
                mode == .signIn ? "Use the password for this account." : "At least 8 characters. Make it strong."
            )

            VStack(spacing: 12) {
                HStack {
                    emailPill
                    Spacer()
                }

                authField(
                    SecureField("Password", text: $password)
                        .textContentType(mode == .signIn ? .password : .newPassword)
                        .focused($focusedField, equals: .password)
                        .submitLabel(.go)
                        .onSubmit { Task { await submitCredentials() } },
                    focused: focusedField == .password
                )

                if mode == .signUp, !password.isEmpty {
                    PasswordStrengthBar(password: password)
                        .transition(.opacity.combined(with: .move(edge: .top)))
                }

                primaryButton(
                    mode == .signIn ? "Sign in" : "Create account",
                    disabled: password.count < (mode == .signIn ? 1 : 8)
                ) {
                    await submitCredentials()
                }
            }
            .animation(.spring(response: 0.35, dampingFraction: 0.85), value: password.isEmpty)

            Group {
                if mode == .signIn {
                    Button("Forgot password?") {
                        Task { await submitReset() }
                    }
                    .font(.system(size: 14.5, weight: .medium))
                    .foregroundStyle(WTheme.accent)
                    .disabled(busy)
                } else {
                    termsFootnote
                }
            }
            .frame(maxWidth: .infinity)
            .padding(.top, 18)
        }
        .onAppear { focusedField = .password }
    }

    private var termsFootnote: some View {
        VStack(spacing: 2) {
            Text("By creating an account, you agree to the")
            HStack(spacing: 4) {
                Link("Terms of Service", destination: URL(string: "https://warmbly.com/terms")!)
                    .foregroundStyle(WTheme.accent)
                Text("and")
                Link("Privacy Policy", destination: URL(string: "https://warmbly.com/privacy")!)
                    .foregroundStyle(WTheme.accent)
            }
        }
        .font(.system(size: 12.5))
        .foregroundStyle(.secondary)
        .frame(maxWidth: .infinity)
    }

    // MARK: - Step: verify (emailed code)

    private var verifyStep: some View {
        VStack(alignment: .leading, spacing: 0) {
            sheetTitle("Check your email", "We sent a 6-digit code to \(email).")

            VStack(spacing: 18) {
                OTPCodeField(code: $code) { complete in
                    Task { await submitEmailCode(complete) }
                }

                Group {
                    if busy {
                        ProgressView()
                    } else if resendRemaining > 0 {
                        Text("Resend code in \(resendRemaining)s")
                            .font(.system(size: 14.5))
                            .foregroundStyle(.secondary)
                            .monospacedDigit()
                            .contentTransition(.numericText(countsDown: true))
                    } else {
                        Button("Resend code") {
                            Task { await resendCode() }
                        }
                        .font(.system(size: 14.5, weight: .semibold))
                        .foregroundStyle(WTheme.accent)
                    }
                }
                .frame(height: 24)
            }
            .frame(maxWidth: .infinity)
        }
    }

    // MARK: - Step: 2FA

    private var twoFAStep: some View {
        VStack(alignment: .leading, spacing: 0) {
            sheetTitle(
                "Two-factor authentication",
                useRecovery ? "Enter one of your recovery codes." : "Enter the code from your authenticator app."
            )

            VStack(spacing: 16) {
                if useRecovery {
                    authField(
                        TextField("xxxxx-xxxxx", text: $recoveryCode)
                            .font(.system(size: 18, design: .monospaced))
                            .multilineTextAlignment(.center)
                            .textInputAutocapitalization(.never)
                            .autocorrectionDisabled()
                            .focused($focusedField, equals: .recovery)
                            .submitLabel(.go)
                            .onSubmit { Task { await submitTwoFA(recoveryCode) } },
                        focused: focusedField == .recovery
                    )
                    primaryButton("Verify", disabled: recoveryCode.trimmingCharacters(in: .whitespaces).isEmpty) {
                        await submitTwoFA(recoveryCode)
                    }
                } else {
                    OTPCodeField(code: $code) { complete in
                        Task { await submitTwoFA(complete) }
                    }
                    if busy { ProgressView() }
                }

                Button(useRecovery ? "Use your authenticator app" : "Use a recovery code") {
                    withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
                        useRecovery.toggle()
                        code = ""
                        recoveryCode = ""
                        errorMessage = nil
                    }
                }
                .font(.system(size: 14.5, weight: .semibold))
                .foregroundStyle(WTheme.accent)
            }
            .frame(maxWidth: .infinity)
        }
    }

    // MARK: - Step: reset sent

    private var resetSentStep: some View {
        VStack(alignment: .leading, spacing: 0) {
            sheetTitle("Reset link sent", "If an account exists for \(email), a reset link is on its way. Complete the reset from the email, then sign in with your new password.")

            primaryButton("Back to sign in", disabled: false) {
                restart()
            }
        }
    }

    // MARK: - Actions

    private func restart() {
        code = ""
        recoveryCode = ""
        useRecovery = false
        registerFlow = false
        infoMessage = nil
        go(to: .email, direction: -1)
    }

    private func showError(_ message: String) {
        withAnimation(.easeOut(duration: 0.2)) {
            errorMessage = message
            infoMessage = nil
        }
        errorPulse += 1
    }

    private func run(_ work: () async throws -> Void) async {
        busy = true
        errorMessage = nil
        do {
            try await work()
        } catch {
            showError((error as? APIError)?.errorDescription ?? error.localizedDescription)
        }
        busy = false
    }

    private func submitCredentials() async {
        guard !busy, password.count >= (mode == .signIn ? 1 : 8) else { return }
        await run {
            switch mode {
            case .signIn:
                registerFlow = false
                // A deployment can sign someone in without emailing a code, so
                // the first call may already be the whole login.
                let outcome = try await env.session.startLogin(email: email, password: password)
                switch outcome {
                case .loggedIn:
                    return
                case let .needsTwoFA(pendingToken):
                    code = ""
                    go(to: .twoFA(pendingToken: pendingToken))
                    return
                case let .needsCode(session):
                    code = ""
                    infoMessage = nil
                    resendRemaining = 60
                    go(to: .verify(session: session))
                }
            case .signUp:
                registerFlow = true
                // With email verification off the account already exists, so
                // fall straight through to signing in.
                guard let session = try await env.session.startRegister(email: email, password: password) else {
                    registerFlow = false
                    let outcome = try await env.session.startLogin(email: email, password: password)
                    switch outcome {
                    case .loggedIn:
                        return
                    case let .needsTwoFA(pendingToken):
                        code = ""
                        go(to: .twoFA(pendingToken: pendingToken))
                    case let .needsCode(loginSession):
                        code = ""
                        resendRemaining = 60
                        go(to: .verify(session: loginSession))
                    }
                    return
                }
                code = ""
                infoMessage = nil
                resendRemaining = 60
                go(to: .verify(session: session))
            }
        }
    }

    private func submitEmailCode(_ submitted: String) async {
        guard case let .verify(session) = step, !busy else { return }
        await run {
            if registerFlow {
                try await env.session.confirmRegister(session: session, code: submitted)
                // Registration returns no tokens; chain into login with the same credentials.
                let outcome = try await env.session.startLogin(email: email, password: password)
                registerFlow = false
                code = ""
                resendRemaining = 60
                switch outcome {
                case .loggedIn:
                    return
                case let .needsTwoFA(pendingToken):
                    go(to: .twoFA(pendingToken: pendingToken))
                case let .needsCode(loginSession):
                    withAnimation(.easeOut(duration: 0.2)) {
                        infoMessage = "Account created. We emailed you a sign-in code."
                    }
                    go(to: .verify(session: loginSession))
                }
            } else {
                let outcome = try await env.session.confirmLogin(session: session, code: submitted)
                if case let .needsTwoFA(pendingToken) = outcome {
                    code = ""
                    go(to: .twoFA(pendingToken: pendingToken))
                }
            }
        }
        if errorMessage != nil { code = "" }
    }

    private func resendCode() async {
        guard case .verify = step else { return }
        await run {
            let session: String?
            if registerFlow {
                session = try await env.session.startRegister(email: email, password: password)
            } else {
                if case let .needsCode(loginSession) = try await env.session.startLogin(email: email, password: password) {
                    session = loginSession
                } else {
                    // The retry resolved the login outright; nothing to resend.
                    return
                }
            }
            guard let session else { return }
            code = ""
            resendRemaining = 60
            step = .verify(session: session)
            withAnimation(.easeOut(duration: 0.2)) {
                infoMessage = "Code resent."
            }
        }
    }

    private func submitTwoFA(_ submitted: String) async {
        guard case let .twoFA(pendingToken) = step, !busy else { return }
        await run {
            try await env.session.verifyTwoFA(pendingToken: pendingToken, code: submitted.trimmingCharacters(in: .whitespaces))
        }
        if errorMessage != nil { code = "" }
    }

    private func submitReset() async {
        guard isPlausibleEmail(email) else {
            showError("Enter your email address first.")
            return
        }
        await run {
            try await env.session.requestPasswordReset(email: email)
            go(to: .resetSent)
        }
    }

    private func isPlausibleEmail(_ value: String) -> Bool {
        let trimmed = value.trimmingCharacters(in: .whitespaces)
        guard let at = trimmed.firstIndex(of: "@"), at != trimmed.startIndex else { return false }
        let domain = trimmed[trimmed.index(after: at)...]
        return domain.contains(".") && !domain.hasPrefix(".") && !domain.hasSuffix(".")
    }
}

// MARK: - Hero ambience

/// Ambient motion around the hero mark: a dashed flight path that keeps
/// redrawing itself across the sky, and a few envelopes drifting upward at
/// different depths. Pure decoration, drawn in one Canvas pass.
struct HeroFlightScene: View {
    private struct EnvelopeSpec {
        let x: CGFloat
        let size: CGFloat
        let period: Double
        let phase: Double
        let baseOpacity: Double
    }

    private static let envelopes: [EnvelopeSpec] = [
        EnvelopeSpec(x: 0.14, size: 20, period: 13, phase: 0.0, baseOpacity: 0.4),
        EnvelopeSpec(x: 0.84, size: 15, period: 17, phase: 0.45, baseOpacity: 0.3),
        EnvelopeSpec(x: 0.32, size: 12, period: 21, phase: 0.8, baseOpacity: 0.22),
    ]

    var body: some View {
        TimelineView(.animation(minimumInterval: 1 / 40)) { context in
            let t = context.date.timeIntervalSinceReferenceDate
            Canvas { ctx, size in
                let rect = CGRect(origin: .zero, size: size)
                let trail = Self.trailPath(in: rect)

                // The faint full route, plus a brighter segment forever
                // travelling along it like a gust carrying the plane.
                ctx.stroke(trail, with: .color(.white.opacity(0.14)), style: StrokeStyle(lineWidth: 2, lineCap: .round, dash: [2, 9]))
                let phase = (t / 6).truncatingRemainder(dividingBy: 1)
                let window: CGFloat = 0.26
                let segment = trail.trimmedPath(from: CGFloat(phase), to: min(CGFloat(phase) + window, 1))
                ctx.stroke(segment, with: .color(.white.opacity(0.5)), style: StrokeStyle(lineWidth: 2.4, lineCap: .round, dash: [2, 9]))

                // Envelopes rising with a gentle sway, fading at both ends.
                for spec in Self.envelopes {
                    let progress = ((t / spec.period) + spec.phase).truncatingRemainder(dividingBy: 1)
                    let y = size.height * (1.08 - 1.3 * progress)
                    let x = size.width * spec.x + sin(t / 2.6 + spec.phase * 9) * 13
                    let fade = sin(.pi * progress)
                    var image = ctx.resolve(Image(systemName: "envelope.fill"))
                    image.shading = .color(.white.opacity(spec.baseOpacity * fade))
                    let box = CGRect(x: x - spec.size / 2, y: y - spec.size / 2, width: spec.size, height: spec.size * 0.72)
                    ctx.draw(image, in: box)
                }
            }
        }
        .allowsHitTesting(false)
    }

    /// A wind current arcing over the mark from off-screen left to right.
    private static func trailPath(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.minX - 24, y: rect.height * 0.66))
        path.addCurve(
            to: CGPoint(x: rect.midX, y: rect.height * 0.14),
            control1: CGPoint(x: rect.width * 0.18, y: rect.height * 0.62),
            control2: CGPoint(x: rect.width * 0.3, y: rect.height * 0.14)
        )
        path.addCurve(
            to: CGPoint(x: rect.maxX + 24, y: rect.height * 0.5),
            control1: CGPoint(x: rect.width * 0.72, y: rect.height * 0.14),
            control2: CGPoint(x: rect.width * 0.86, y: rect.height * 0.42)
        )
        return path
    }
}

// MARK: - Feature showcase

/// Rotating product vignettes filling the sky while the email step is at
/// rest: glass cards with animated white-line scenes of what Warmbly does.
/// Auto-advances, swipeable, dots to jump.
struct FeatureShowcase: View {
    /// Natural height (cards + dots); the auth screen scales the whole
    /// showcase down from this when the sky gets a little tight.
    static let idealHeight: CGFloat = 256

    private struct Slide: Identifiable {
        let id: Int
        let title: String
        let subtitle: String
    }

    private static let slides: [Slide] = [
        Slide(id: 0, title: "Warm up on autopilot", subtitle: "Every inbox ramps up gradually and builds sender reputation on its own."),
        Slide(id: 1, title: "Land in the inbox", subtitle: "Watch deliverability live and catch spam placement before it hurts."),
        Slide(id: 2, title: "Every reply, one place", subtitle: "All your mailboxes and your team in a single unified inbox."),
        Slide(id: 3, title: "Know what works", subtitle: "Opens, clicks, and replies per campaign, at a glance."),
    ]

    @State private var index = 0
    @State private var float = false

    var body: some View {
        VStack(spacing: 13) {
            TabView(selection: $index) {
                ForEach(Self.slides) { slide in
                    card(slide)
                        .padding(.horizontal, 5)
                        .tag(slide.id)
                }
            }
            .tabViewStyle(.page(indexDisplayMode: .never))
            .frame(height: 226)

            HStack(spacing: 7) {
                ForEach(Self.slides) { slide in
                    Button {
                        withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
                            index = slide.id
                        }
                    } label: {
                        Capsule()
                            .fill(.white.opacity(index == slide.id ? 0.95 : 0.35))
                            .frame(width: index == slide.id ? 22 : 7, height: 7)
                    }
                    .buttonStyle(.plain)
                }
            }
            .animation(.spring(response: 0.4, dampingFraction: 0.85), value: index)
        }
        .padding(.bottom, 10)
        .offset(y: float ? -3 : 3)
        .sensoryFeedback(.selection, trigger: index)
        .onAppear {
            withAnimation(.easeInOut(duration: 6).repeatForever(autoreverses: true)) {
                float = true
            }
        }
        .onReceive(Timer.publish(every: 5, on: .main, in: .common).autoconnect()) { _ in
            withAnimation(.spring(response: 0.55, dampingFraction: 0.86)) {
                index = (index + 1) % Self.slides.count
            }
        }
    }

    private func card(_ slide: Slide) -> some View {
        VStack(alignment: .leading, spacing: 0) {
            visual(for: slide.id)
                .frame(maxWidth: .infinity)
                .frame(height: 100)
                .padding(.bottom, 14)

            Text(slide.title)
                .font(.system(size: 18, weight: .bold))
                .tracking(-0.3)
                .foregroundStyle(.white)

            Text(slide.subtitle)
                .font(.system(size: 13.5))
                .foregroundStyle(.white.opacity(0.78))
                .fixedSize(horizontal: false, vertical: true)
                .padding(.top, 5)
        }
        .padding(22)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background {
            RoundedRectangle(cornerRadius: 26)
                .fill(.white.opacity(0.13))
                .overlay(
                    RoundedRectangle(cornerRadius: 26)
                        .strokeBorder(
                            LinearGradient(
                                colors: [.white.opacity(0.45), .white.opacity(0.08)],
                                startPoint: .topLeading,
                                endPoint: .bottomTrailing
                            ),
                            lineWidth: 1
                        )
                )
                .shadow(color: Color(hex: 0x0C4A6E).opacity(0.2), radius: 16, y: 9)
        }
    }

    @ViewBuilder
    private func visual(for id: Int) -> some View {
        switch id {
        case 0: WarmupRampVisual()
        case 1: InboxPlacementVisual()
        case 2: UnifiedInboxVisual()
        default: CampaignStatsVisual()
        }
    }
}

/// The showcase distilled to one line: a glass chip cycling through the
/// feature headlines, for the slim strip of sky left above the keyboard.
struct FeatureTicker: View {
    private static let items: [(icon: String, text: String)] = [
        ("chart.line.uptrend.xyaxis", "Warm up on autopilot"),
        ("checkmark.seal.fill", "Land in the inbox"),
        ("arrowshape.turn.up.left.fill", "Every reply, one place"),
        ("chart.bar.fill", "Know what works"),
    ]

    @State private var index = 0

    var body: some View {
        ZStack {
            HStack(spacing: 8) {
                Image(systemName: Self.items[index].icon)
                    .font(.system(size: 12, weight: .semibold))
                Text(Self.items[index].text)
                    .font(.system(size: 13.5, weight: .semibold))
                    .fixedSize()
            }
            .foregroundStyle(.white)
            .padding(.horizontal, 15)
            .frame(height: 36)
            .background(.white.opacity(0.14), in: Capsule())
            .overlay(Capsule().strokeBorder(.white.opacity(0.25), lineWidth: 1))
            .id(index)
            .transition(.blurReplace)
        }
        .onReceive(Timer.publish(every: 3.5, on: .main, in: .common).autoconnect()) { _ in
            withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) {
                index = (index + 1) % Self.items.count
            }
        }
    }
}

/// A send-volume ramp drawing itself in over faint gridlines, with a glowing
/// live dot at today's volume.
struct WarmupRampVisual: View {
    @State private var progress: CGFloat = 0
    @State private var pulse = false

    var body: some View {
        GeometryReader { proxy in
            let size = proxy.size
            ZStack(alignment: .topLeading) {
                VStack(spacing: 0) {
                    ForEach(0 ..< 3, id: \.self) { _ in
                        Rectangle().fill(.white.opacity(0.1)).frame(height: 1)
                        Spacer()
                    }
                    Rectangle().fill(.white.opacity(0.22)).frame(height: 1)
                }

                Self.area(in: size)
                    .fill(
                        LinearGradient(
                            colors: [.white.opacity(0.22), .white.opacity(0.02)],
                            startPoint: .top,
                            endPoint: .bottom
                        )
                    )
                    .opacity(progress)

                Self.ramp(in: size)
                    .trim(from: 0, to: progress)
                    .stroke(.white, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))

                Circle()
                    .fill(.white)
                    .frame(width: 7, height: 7)
                    .background(
                        Circle()
                            .fill(.white.opacity(0.3))
                            .frame(width: pulse ? 24 : 10, height: pulse ? 24 : 10)
                    )
                    .position(x: size.width - 2, y: size.height * 0.08)
                    .opacity(progress > 0.96 ? 1 : 0)
                    .animation(.easeOut(duration: 0.25), value: progress > 0.96)
            }
        }
        .onAppear {
            withAnimation(.easeOut(duration: 1.4).delay(0.25)) { progress = 1 }
            withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: true).delay(1.7)) { pulse = true }
        }
    }

    private static func ramp(in size: CGSize) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: 2, y: size.height * 0.94))
        path.addCurve(
            to: CGPoint(x: size.width * 0.55, y: size.height * 0.6),
            control1: CGPoint(x: size.width * 0.28, y: size.height * 0.94),
            control2: CGPoint(x: size.width * 0.4, y: size.height * 0.72)
        )
        path.addCurve(
            to: CGPoint(x: size.width - 2, y: size.height * 0.08),
            control1: CGPoint(x: size.width * 0.72, y: size.height * 0.47),
            control2: CGPoint(x: size.width * 0.86, y: size.height * 0.1)
        )
        return path
    }

    private static func area(in size: CGSize) -> Path {
        var path = ramp(in: size)
        path.addLine(to: CGPoint(x: size.width - 2, y: size.height))
        path.addLine(to: CGPoint(x: 2, y: size.height))
        path.closeSubpath()
        return path
    }
}

/// A deliverability gauge sweeping up to 98% inbox placement, with the
/// folder split alongside.
struct InboxPlacementVisual: View {
    @State private var progress: CGFloat = 0
    @State private var shownValue = 0

    var body: some View {
        HStack(spacing: 26) {
            ZStack {
                Circle()
                    .stroke(.white.opacity(0.18), lineWidth: 9)
                Circle()
                    .trim(from: 0, to: progress * 0.98)
                    .stroke(.white, style: StrokeStyle(lineWidth: 9, lineCap: .round))
                    .rotationEffect(.degrees(-90))
                Text("\(shownValue)%")
                    .font(.system(size: 22, weight: .bold))
                    .foregroundStyle(.white)
                    .monospacedDigit()
                    .contentTransition(.numericText())
            }
            .frame(width: 96, height: 96)

            VStack(alignment: .leading, spacing: 13) {
                placementRow(icon: "tray.full.fill", label: "Inbox", fraction: 0.98)
                placementRow(icon: "xmark.bin", label: "Spam", fraction: 0.02)
            }
        }
        .padding(.horizontal, 4)
        .onAppear {
            withAnimation(.easeOut(duration: 1.3).delay(0.25)) {
                progress = 1
                shownValue = 98
            }
        }
    }

    private func placementRow(icon: String, label: String, fraction: CGFloat) -> some View {
        VStack(alignment: .leading, spacing: 5) {
            HStack(spacing: 6) {
                Image(systemName: icon)
                    .font(.system(size: 10.5))
                Text(label)
                    .font(.system(size: 12.5, weight: .semibold))
            }
            .foregroundStyle(.white.opacity(0.85))

            GeometryReader { proxy in
                ZStack(alignment: .leading) {
                    Capsule().fill(.white.opacity(0.18))
                    Capsule()
                        .fill(.white.opacity(0.9))
                        .frame(width: max(5, proxy.size.width * fraction * progress))
                }
            }
            .frame(height: 5)
        }
    }
}

/// Message rows from different providers sliding into one inbox, one of them
/// already answered.
struct UnifiedInboxVisual: View {
    @State private var shown = false

    private struct Row {
        let initial: String
        let lineWidth: CGFloat
        let replied: Bool
        let unread: Bool
    }

    private static let rows: [Row] = [
        Row(initial: "G", lineWidth: 0.74, replied: false, unread: true),
        Row(initial: "M", lineWidth: 0.52, replied: true, unread: false),
        Row(initial: "O", lineWidth: 0.63, replied: false, unread: false),
    ]

    var body: some View {
        VStack(spacing: 8) {
            ForEach(Array(Self.rows.enumerated()), id: \.offset) { pair in
                let row = pair.element
                HStack(spacing: 10) {
                    Circle()
                        .fill(.white.opacity(0.28))
                        .frame(width: 26, height: 26)
                        .overlay(
                            Text(row.initial)
                                .font(.system(size: 11.5, weight: .bold))
                                .foregroundStyle(.white)
                        )

                    VStack(alignment: .leading, spacing: 4) {
                        GeometryReader { proxy in
                            Capsule()
                                .fill(.white.opacity(0.75))
                                .frame(width: proxy.size.width * row.lineWidth, height: 5)
                        }
                        .frame(height: 5)
                        Capsule()
                            .fill(.white.opacity(0.3))
                            .frame(width: 52, height: 5)
                    }

                    if row.replied {
                        Image(systemName: "arrowshape.turn.up.left.fill")
                            .font(.system(size: 11))
                            .foregroundStyle(.white.opacity(0.85))
                    }
                    if row.unread {
                        Circle().fill(.white).frame(width: 7, height: 7)
                    }
                }
                .padding(.horizontal, 12)
                .padding(.vertical, 8)
                .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 13))
                .offset(x: shown ? 0 : 46)
                .opacity(shown ? 1 : 0)
                .animation(
                    .spring(response: 0.55, dampingFraction: 0.8).delay(0.2 + Double(pair.offset) * 0.13),
                    value: shown
                )
            }
        }
        .onAppear { shown = true }
    }
}

/// Campaign results growing in: opens, clicks, replies as labeled bars.
struct CampaignStatsVisual: View {
    @State private var grown = false

    private static let bars: [(label: String, fraction: CGFloat)] = [
        ("Opened", 0.72),
        ("Clicked", 0.44),
        ("Replied", 0.2),
    ]

    var body: some View {
        HStack(alignment: .bottom, spacing: 22) {
            ForEach(Array(Self.bars.enumerated()), id: \.offset) { pair in
                let bar = pair.element
                VStack(spacing: 7) {
                    Text("\(Int(bar.fraction * 100))%")
                        .font(.system(size: 12, weight: .bold))
                        .foregroundStyle(.white.opacity(0.9))
                        .monospacedDigit()
                        .opacity(grown ? 1 : 0)

                    VStack {
                        Spacer(minLength: 0)
                        RoundedRectangle(cornerRadius: 5)
                            .fill(.white.opacity(0.9 - Double(pair.offset) * 0.18))
                            .frame(height: grown ? 66 * bar.fraction + 8 : 6)
                    }
                    .frame(height: 74)

                    Text(bar.label)
                        .font(.system(size: 11.5, weight: .semibold))
                        .foregroundStyle(.white.opacity(0.7))
                }
                .frame(width: 62)
                .animation(
                    .spring(response: 0.6, dampingFraction: 0.75).delay(0.2 + Double(pair.offset) * 0.12),
                    value: grown
                )
            }
        }
        .onAppear { grown = true }
    }
}

// MARK: - Button press feel

/// Springy scale-down on press, shared by every tappable in the auth flow.
struct PressableButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.965 : 1)
            .opacity(configuration.isPressed ? 0.88 : 1)
            .animation(.spring(response: 0.28, dampingFraction: 0.7), value: configuration.isPressed)
    }
}

// MARK: - Password strength

/// Lightweight local strength meter (the server only enforces the 8-char
/// minimum; this is guidance, not a gate).
struct PasswordStrengthBar: View {
    let password: String

    private var assessment: (score: Int, label: String, color: Color) {
        var score = 0
        if password.count >= 8 { score += 1 }
        if password.count >= 12 { score += 1 }
        var classes = 0
        if password.contains(where: \.isLowercase) { classes += 1 }
        if password.contains(where: \.isUppercase) { classes += 1 }
        if password.contains(where: \.isNumber) { classes += 1 }
        if password.contains(where: { !$0.isLetter && !$0.isNumber }) { classes += 1 }
        if classes >= 2 { score += 1 }
        if classes >= 3, password.count >= 10 { score += 1 }

        switch score {
        case 0 ... 1: return (max(score, 1), "Weak", WTheme.negative)
        case 2: return (2, "Fair", WTheme.warning)
        case 3: return (3, "Good", WTheme.info)
        default: return (4, "Strong", WTheme.positive)
        }
    }

    var body: some View {
        let result = assessment
        VStack(alignment: .leading, spacing: 6) {
            GeometryReader { proxy in
                ZStack(alignment: .leading) {
                    Capsule().fill(Color(.secondarySystemBackground))
                    Capsule()
                        .fill(result.color)
                        .frame(width: proxy.size.width * CGFloat(result.score) / 4)
                        .animation(.spring(response: 0.35, dampingFraction: 0.8), value: result.score)
                }
            }
            .frame(height: 5)

            Text("\(result.label) password")
                .font(.system(size: 12.5))
                .foregroundStyle(.secondary)
        }
    }
}

// MARK: - The website's airy sky

/// The marketing site's auth backdrop, recreated from `web/src/global.css`:
/// a radial sky, a slow "breathing" light layer, a warm sun glow top-right,
/// and the site's painterly cloud renders gliding gently back and forth.
struct SkyBackdrop: View {
    var largeCloud = false

    @State private var breathe = false
    @State private var glide = false

    private static let skyStops: [Gradient.Stop] = [
        .init(color: Color(hex: 0x7DD3FC), location: 0),
        .init(color: Color(hex: 0x38BDF8), location: 0.20),
        .init(color: Color(hex: 0x0EA5E9), location: 0.40),
        .init(color: Color(hex: 0x0284C7), location: 0.62),
        .init(color: Color(hex: 0x075985), location: 0.84),
        .init(color: Color(hex: 0x0C4A6E), location: 1),
    ]

    var body: some View {
        GeometryReader { proxy in
            let size = proxy.size
            ZStack {
                // sky-base: radial from the upper area, light to deep.
                RadialGradient(
                    stops: Self.skyStops,
                    center: UnitPoint(x: 0.6, y: 0.18),
                    startRadius: 0,
                    endRadius: max(size.width, size.height) * 1.15
                )

                // sky-breathe: a soft light bloom that swells and fades.
                RadialGradient(
                    stops: [
                        .init(color: Color(hex: 0xBAE6FD).opacity(0.8), location: 0),
                        .init(color: Color(hex: 0x38BDF8).opacity(0.4), location: 0.20),
                        .init(color: Color(hex: 0x0EA5E9).opacity(0.16), location: 0.40),
                        .init(color: .clear, location: 0.62),
                    ],
                    center: UnitPoint(x: 0.6, y: 0.16),
                    startRadius: 0,
                    endRadius: max(size.width, size.height) * 1.05
                )
                .opacity(breathe ? 0.45 : 0.7)
                .scaleEffect(breathe ? 1.05 : 1)

                // sun-glow: warm haze off the top-right corner.
                RadialGradient(
                    stops: [
                        .init(color: Color(hex: 0xFDE68A).opacity(0.34), location: 0),
                        .init(color: Color(hex: 0xFDBA74).opacity(0.12), location: 0.32),
                        .init(color: Color(hex: 0xBAE6FD).opacity(0.05), location: 0.55),
                        .init(color: .clear, location: 1),
                    ],
                    center: .center,
                    startRadius: 0,
                    endRadius: 210
                )
                .frame(width: 420, height: 420)
                .blur(radius: 36)
                .position(x: size.width * 0.96, y: size.height * 0.02)

                // Painterly clouds (the site's pre-rendered webp art),
                // mirroring the web auth layout's placement and glide.
                Image("Cloud3")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 280)
                    .opacity(0.6)
                    .position(x: size.width * -0.16 + 140, y: size.height * 0.04 + 73)
                    .offset(x: glide ? 40 : 0, y: glide ? -10 : 0)

                Image("Cloud4")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 240)
                    .opacity(0.5)
                    .position(x: size.width * 1.14 - 120, y: size.height * 0.88 - 63)
                    .offset(x: glide ? -38 : 0, y: glide ? 8 : 0)

                if largeCloud {
                    Image("Cloud1")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 360)
                        .opacity(0.35)
                        .position(x: size.width * 0.85, y: size.height * 0.42)
                        .offset(x: glide ? -24 : 0, y: glide ? 6 : 0)
                }
            }
        }
        .ignoresSafeArea()
        .onAppear {
            withAnimation(.easeInOut(duration: 15).repeatForever(autoreverses: true)) {
                breathe = true
            }
            withAnimation(.easeInOut(duration: 19).repeatForever(autoreverses: true)) {
                glide = true
            }
        }
    }
}

/// Where the app connects, for local dev or a self-hosted deployment.
/// Presets cover the common cases in one tap; the captcha token and realtime
/// origin live behind Advanced so the everyday view stays simple.
struct ServerConfigSheet: View {
    @Environment(\.dismiss) private var dismiss
    @State private var origin = AppConfig.serverOrigin
    @State private var turnstile = AppConfig.turnstileToken
    @State private var realtime = AppConfig.realtimeOriginOverride
    @State private var showAdvanced = false

    @FocusState private var focused: Field?
    private enum Field { case origin, turnstile, realtime }

    private var isDefault: Bool {
        origin == AppConfig.defaultOrigin
            && turnstile == AppConfig.defaultTurnstileToken
            && realtime.isEmpty
    }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                HStack(alignment: .center, spacing: 14) {
                    Image(systemName: "globe")
                        .font(.system(size: 20, weight: .semibold))
                        .foregroundStyle(.white)
                        .frame(width: 46, height: 46)
                        .background(
                            LinearGradient(colors: [Color(hex: 0x38BDF8), Color(hex: 0x0284C7)], startPoint: .topLeading, endPoint: .bottomTrailing),
                            in: RoundedRectangle(cornerRadius: 14)
                        )

                    VStack(alignment: .leading, spacing: 3) {
                        Text("Connection")
                            .font(.system(size: 24, weight: .bold))
                            .tracking(-0.5)
                        Text("One app, any Warmbly server.")
                            .font(.system(size: 14))
                            .foregroundStyle(.secondary)
                    }

                    Spacer(minLength: 12)

                    Button {
                        dismiss()
                    } label: {
                        Image(systemName: "xmark")
                            .font(.system(size: 13, weight: .semibold))
                            .foregroundStyle(.secondary)
                            .frame(width: 32, height: 32)
                            .background(Color(.secondarySystemBackground), in: Circle())
                    }
                    .buttonStyle(PressableButtonStyle())
                    .accessibilityLabel("Close")
                }
                .padding(.bottom, 24)

                // One-tap environments.
                HStack(spacing: 10) {
                    presetChip("Warmbly Cloud", icon: "cloud.fill", active: origin == "https://api.warmbly.com") {
                        origin = "https://api.warmbly.com"
                        turnstile = ""
                        realtime = ""
                    }
                    presetChip("Local dev", icon: "laptopcomputer", active: origin == "http://localhost:8080") {
                        origin = "http://localhost:8080"
                        turnstile = "warmbly-local-turnstile-bypass"
                        realtime = ""
                    }
                }
                .padding(.bottom, 24)

                fieldLabel("Server", icon: "server.rack")
                configField(
                    TextField(AppConfig.defaultOrigin, text: $origin)
                        .keyboardType(.URL)
                        .textContentType(.URL)
                        .textInputAutocapitalization(.never)
                        .autocorrectionDisabled()
                        .focused($focused, equals: .origin),
                    focused: focused == .origin
                )
                caption("Bare origin, no path. The app calls <origin>/v1. On a physical device, localhost won't reach your Mac; use your Mac's LAN IP or a tunnel URL instead.")
                    .padding(.bottom, 18)

                // Advanced: rarely needed, one quiet toggle, fields styled
                // exactly like the rest of the form.
                Button {
                    withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
                        showAdvanced.toggle()
                    }
                } label: {
                    HStack(spacing: 5) {
                        Text(showAdvanced ? "Hide advanced options" : "Advanced options")
                            .font(.system(size: 14, weight: .medium))
                        Image(systemName: "chevron.down")
                            .font(.system(size: 10, weight: .semibold))
                            .rotationEffect(.degrees(showAdvanced ? 180 : 0))
                    }
                    .foregroundStyle(WTheme.accent)
                }
                .buttonStyle(PressableButtonStyle())
                .padding(.vertical, 2)

                if showAdvanced {
                    VStack(alignment: .leading, spacing: 0) {
                        fieldLabel("Captcha token", icon: "checkmark.shield")
                            .padding(.top, 20)
                        configField(
                            TextField("Turnstile token", text: $turnstile)
                                .textInputAutocapitalization(.never)
                                .autocorrectionDisabled()
                                .focused($focused, equals: .turnstile),
                            focused: focused == .turnstile
                        )
                        caption("Only for self-hosted setups. Dev backends accept the bypass token; the hosted service needs nothing here.")
                            .padding(.bottom, 18)

                        fieldLabel("Realtime server", icon: "bolt.horizontal")
                        configField(
                            TextField("Same as server (automatic)", text: $realtime)
                                .keyboardType(.URL)
                                .textInputAutocapitalization(.never)
                                .autocorrectionDisabled()
                                .focused($focused, equals: .realtime),
                            focused: focused == .realtime
                        )
                        caption("Optional. Where live updates connect when your realtime service runs on a different host. Leave empty to let the server decide.")
                    }
                    .transition(.opacity.combined(with: .move(edge: .top)))
                }

                if !isDefault {
                    Button("Reset to default") {
                        withAnimation(.easeOut(duration: 0.2)) {
                            origin = AppConfig.defaultOrigin
                            turnstile = AppConfig.defaultTurnstileToken
                            realtime = ""
                        }
                    }
                    .font(.system(size: 14.5, weight: .medium))
                    .foregroundStyle(WTheme.accent)
                    .frame(maxWidth: .infinity)
                    .padding(.top, 22)
                }
            }
            .padding(24)
        }
        .scrollBounceBehavior(.basedOnSize)
        .safeAreaInset(edge: .bottom) {
            Button {
                AppConfig.serverOrigin = AppConfig.normalizeOrigin(origin)
                AppConfig.turnstileToken = turnstile.trimmingCharacters(in: .whitespaces)
                AppConfig.realtimeOriginOverride = AppConfig.normalizeOrigin(realtime)
                dismiss()
            } label: {
                Text("Save")
                    .font(.system(size: 17, weight: .semibold))
                    .foregroundStyle(.white)
                    .frame(maxWidth: .infinity)
                    .frame(height: 56)
                    .background(
                        LinearGradient(colors: [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)], startPoint: .top, endPoint: .bottom),
                        in: RoundedRectangle(cornerRadius: 17)
                    )
                    .shadow(color: Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6)
            }
            .buttonStyle(PressableButtonStyle())
            .padding(.horizontal, 24)
            .padding(.top, 10)
            .padding(.bottom, 6)
            .background(Color(.systemBackground))
        }
        .presentationBackground(Color(.systemBackground))
        .presentationDetents([.medium, .large])
        .presentationDragIndicator(.visible)
    }

    private func presetChip(_ title: String, icon: String, active: Bool, action: @escaping () -> Void) -> some View {
        Button {
            withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { action() }
        } label: {
            HStack(spacing: 7) {
                Image(systemName: icon)
                    .font(.system(size: 12.5))
                Text(title)
                    .font(.system(size: 14, weight: .semibold))
            }
            .foregroundStyle(active ? .white : .primary)
            .padding(.horizontal, 15)
            .frame(height: 38)
            .background(
                active ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Color(.secondarySystemBackground)),
                in: Capsule()
            )
        }
        .buttonStyle(PressableButtonStyle())
    }

    private func fieldLabel(_ text: String, icon: String) -> some View {
        HStack(spacing: 6) {
            Image(systemName: icon)
                .font(.system(size: 11))
            Text(text.uppercased())
                .font(.system(size: 11, weight: .semibold))
                .tracking(1.3)
        }
        .foregroundStyle(.secondary)
        .padding(.bottom, 8)
    }

    private func configField(_ content: some View, focused: Bool) -> some View {
        content
            .font(.system(size: 15.5))
            .padding(.horizontal, 15)
            .frame(height: 52)
            .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 15))
            .overlay(
                RoundedRectangle(cornerRadius: 15)
                    .strokeBorder(focused ? WTheme.accent : .clear, lineWidth: 1.8)
            )
            .animation(.easeOut(duration: 0.18), value: focused)
            .padding(.bottom, 8)
    }

    private func caption(_ text: String) -> some View {
        Text(text)
            .font(.system(size: 12.5))
            .foregroundStyle(.tertiary)
            .fixedSize(horizontal: false, vertical: true)
    }
}
