'use client';

import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';

type State = 'bootstrap' | 'configured' | 'env-managed';

interface StatusResponse {
  state: State;
  authenticated: boolean;
  readOnly: boolean;
  partialConfig: Record<string, unknown> | null;
}

interface JmapServerRow {
  id: string;
  label: string;
  url: string;
  /** comma-separated, parsed before save */
  domains: string;
}

interface WizardConfig {
  // Server
  appName: string;
  jmapServerUrl: string;
  stalwartFeaturesEnabled: boolean;
  jmapServers: JmapServerRow[];
  jmapServerAutoPickByDomain: boolean;
  // Auth
  oauthEnabled: boolean;
  oauthOnly: boolean;
  oauthClientId: string;
  oauthClientSecret: string;
  oauthIssuerUrl: string;
  // Security
  sessionSecret: string;
  settingsSyncEnabled: boolean;
  // Logging
  logFormat: 'text' | 'json';
  logLevel: 'error' | 'warn' | 'info' | 'debug';
  // Branding
  faviconUrl: string;
  appLogoLightUrl: string;
  appLogoDarkUrl: string;
  loginLogoLightUrl: string;
  loginLogoDarkUrl: string;
  loginCompanyName: string;
  loginImprintUrl: string;
  loginPrivacyPolicyUrl: string;
  loginWebsiteUrl: string;
}

const EMPTY_CONFIG: WizardConfig = {
  appName: 'Bulwark Webmail',
  jmapServerUrl: '',
  stalwartFeaturesEnabled: true,
  jmapServers: [],
  jmapServerAutoPickByDomain: false,
  oauthEnabled: false,
  oauthOnly: false,
  oauthClientId: '',
  oauthClientSecret: '',
  oauthIssuerUrl: '',
  sessionSecret: '',
  settingsSyncEnabled: true,
  logFormat: 'text',
  logLevel: 'info',
  faviconUrl: '',
  appLogoLightUrl: '',
  appLogoDarkUrl: '',
  loginLogoLightUrl: '',
  loginLogoDarkUrl: '',
  loginCompanyName: '',
  loginImprintUrl: '',
  loginPrivacyPolicyUrl: '',
  loginWebsiteUrl: '',
};

const STEPS = [
  { id: 'welcome', label: 'Welcome' },
  { id: 'server', label: 'Server' },
  { id: 'auth', label: 'Auth' },
  { id: 'security', label: 'Security' },
  { id: 'logging', label: 'Logging' },
  { id: 'branding', label: 'Branding' },
  { id: 'review', label: 'Review' },
] as const;

export default function SetupWizardPage() {
  const router = useRouter();
  const searchParams = useSearchParams();

  const [bootstrapping, setBootstrapping] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [state, setState] = useState<State>('bootstrap');
  const [authenticated, setAuthenticated] = useState(false);
  const [readOnly, setReadOnly] = useState(false);
  const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
  const [stepIndex, setStepIndex] = useState(0);
  const [completed, setCompleted] = useState(false);
  // Detect synchronously on first client render so we don't flash the loading
  // screen before the warning appears. The session cookie is set with the
  // Secure flag in production, which browsers silently drop over plain HTTP -
  // every subsequent step call then 401s with "Wizard session required".
  const [insecureContext] = useState<boolean>(detectInsecureContext);

  // ─── Initial status load ────────────────────────────────────────────────
  useEffect(() => {
    // Skip the status fetch entirely when we're going to render the HTTPS
    // notice - the wizard cookie can't survive an HTTP origin anyway.
    if (insecureContext) {
      setBootstrapping(false);
      return;
    }
    let cancelled = false;
    (async () => {
      try {
        const res = await apiFetch('/api/setup/status', { cache: 'no-store' });
        const data = (await res.json()) as StatusResponse;
        if (cancelled) return;

        setState(data.state);
        setReadOnly(data.readOnly);

        if (data.state === 'configured' || data.state === 'env-managed') {
          // Wizard not active - bounce to login. Middleware will 404 us
          // before we get here in practice, but defensive.
          router.replace('/');
          return;
        }

        setAuthenticated(data.authenticated);
        if (data.partialConfig) {
          setConfig((prev) => mergePartial(prev, data.partialConfig!));
          // If auth is OK and we already have a JMAP URL persisted, jump
          // ahead to the next unfilled step.
          if (data.authenticated && data.partialConfig.jmapServerUrl) {
            setStepIndex(2);
          } else if (data.authenticated) {
            setStepIndex(1);
          }
        }
      } catch (e) {
        if (!cancelled) setError(humanError(e));
      } finally {
        if (!cancelled) setBootstrapping(false);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [router, insecureContext]);

  // ─── Token submit (welcome step) ────────────────────────────────────────
  async function submitToken(token: string) {
    setError(null);
    const res = await apiFetch('/api/setup/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token }),
    });
    if (!res.ok) {
      const data = await res.json().catch(() => ({}));
      throw new Error(data.error ?? `Token rejected (HTTP ${res.status})`);
    }
    setAuthenticated(true);
    setStepIndex(1);
  }

  // ─── Step persistence ───────────────────────────────────────────────────
  async function saveStep(step: string, values: Record<string, unknown>) {
    const res = await apiFetch('/api/setup/step', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ step, values }),
    });
    if (!res.ok) {
      const data = await res.json().catch(() => ({}));
      throw new Error(data.error ?? `Step save failed (HTTP ${res.status})`);
    }
  }

  // ─── Render shell ───────────────────────────────────────────────────────
  if (insecureContext) {
    return <InsecureContextScreen />;
  }

  if (bootstrapping) {
    return <CenteredCard><p className="text-muted-foreground">Loading…</p></CenteredCard>;
  }

  if (completed) {
    return <CompletedScreen />;
  }

  if (state !== 'bootstrap') {
    return <AlreadyConfiguredScreen />;
  }

  if (readOnly) {
    return (
      <CenteredCard>
        <h1 className="text-lg font-semibold">Configuration is read-only</h1>
        <p className="text-sm text-muted-foreground mt-2">
          The config volume is mounted read-only or <code className="font-mono text-xs">ADMIN_CONFIG_READONLY</code> is set.
          Remount it read-write or unset that variable, then restart the container.
        </p>
      </CenteredCard>
    );
  }

  return (
    <div className="min-h-screen flex items-center justify-center px-4 py-8">
      <div className="w-full max-w-2xl">
        <Header />
        <ProgressBar stepIndex={stepIndex} />

        <div className="bg-card border border-border rounded-lg p-6 shadow-sm">
          {error && <ErrorBanner error={error} onDismiss={() => setError(null)} />}

          {!authenticated ? (
            <WelcomeStep
              tokenFromUrl={searchParams.get('token') ?? ''}
              onSubmit={async (t) => {
                try {
                  await submitToken(t);
                } catch (e) {
                  setError(humanError(e));
                }
              }}
            />
          ) : (
            <StepContent
              stepIndex={stepIndex}
              config={config}
              setConfig={setConfig}
              onNext={async (step, values) => {
                try {
                  await saveStep(step, values);
                  setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
                } catch (e) {
                  const msg = humanError(e);
                  setError(msg);
                  // Session expired mid-flow - kick the user back to the
                  // welcome step so they can re-enter the token without
                  // having to refresh.
                  if (/wizard session required/i.test(msg)) {
                    setAuthenticated(false);
                    setStepIndex(0);
                  }
                }
              }}
              onBack={() => setStepIndex((i) => Math.max(i - 1, 1))}
              onFinish={() => {
                setCompleted(true);
                // Hard navigation after a beat - gives the user a moment
                // to see the success screen and works around any router
                // edge cases that swallow client-side replaces after the
                // setupComplete flag flips.
                setTimeout(() => {
                  window.location.assign('/admin/login');
                }, 1500);
              }}
            />
          )}
        </div>
      </div>
    </div>
  );
}

// ─── Layout helpers ───────────────────────────────────────────────────────

function Header() {
  return (
    <div className="text-center mb-6">
      <h1 className="text-2xl font-semibold">Bulwark Webmail Setup</h1>
      <p className="text-sm text-muted-foreground mt-1">
        Configure your webmail instance from the browser.
      </p>
    </div>
  );
}

function ProgressBar({ stepIndex }: { stepIndex: number }) {
  return (
    <div className="mb-6">
      <div className="flex items-center gap-1.5 text-xs">
        {STEPS.map((step, i) => (
          <div key={step.id} className="flex-1">
            <div
              className={
                'h-1 rounded-full transition-colors ' +
                (i < stepIndex
                  ? 'bg-primary'
                  : i === stepIndex
                    ? 'bg-primary/60'
                    : 'bg-muted')
              }
            />
            <div
              className={
                'mt-1.5 text-center ' +
                (i === stepIndex
                  ? 'text-foreground font-medium'
                  : 'text-muted-foreground')
              }
            >
              {step.label}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

function CompletedScreen() {
  return (
    <CenteredCard>
      <div className="text-center">
        <div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
            className="h-6 w-6 text-primary"
          >
            <path d="M20 6 9 17l-5-5" />
          </svg>
        </div>
        <h1 className="text-xl font-semibold">You&apos;re all set!</h1>
        <p className="text-sm text-muted-foreground mt-2">
          Bulwark Webmail is configured and ready to use.
        </p>
      </div>
      <div className="mt-6 space-y-2">
        <a
          href="/admin/login"
          className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
        >
          Sign in to admin dashboard
        </a>
        <a
          href="/"
          className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
        >
          Open webmail login
        </a>
      </div>
      <p className="text-xs text-muted-foreground text-center mt-4">
        Taking you to the admin dashboard…
      </p>
    </CenteredCard>
  );
}

function InsecureContextScreen() {
  const httpsUrl =
    typeof window !== 'undefined'
      ? `https://${window.location.host}${window.location.pathname}${window.location.search}`
      : '';
  return (
    <CenteredCard>
      <div className="text-center">
        <div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4">
          <ShieldAlert className="h-6 w-6" />
        </div>
        <h1 className="text-xl font-semibold">HTTPS required for setup</h1>
        <p className="text-sm text-muted-foreground mt-2">
          The setup wizard signs you in with a <code className="font-mono text-xs">Secure</code> cookie,
          which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every
          step to fail with <em>Wizard session required</em>.
        </p>
      </div>
      <div className="mt-5 text-left text-sm text-muted-foreground space-y-2">
        <p className="font-medium text-foreground">To continue, do one of the following:</p>
        <ul className="list-disc pl-5 space-y-1">
          <li>Reach this page over HTTPS (terminate TLS on the container or a reverse proxy in front of it).</li>
          <li>If you already have a reverse proxy, make sure it forwards to the webmail and forwards the
            <code className="font-mono text-xs"> X-Forwarded-Proto</code> header.</li>
        </ul>
      </div>
      {httpsUrl && (
        <a
          href={httpsUrl}
          className="mt-6 block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
        >
          Open over HTTPS
        </a>
      )}
    </CenteredCard>
  );
}

function AlreadyConfiguredScreen() {
  return (
    <CenteredCard>
      <div className="text-center">
        <div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
            className="h-6 w-6 text-primary"
          >
            <path d="M20 6 9 17l-5-5" />
          </svg>
        </div>
        <h1 className="text-xl font-semibold">Setup is already complete</h1>
        <p className="text-sm text-muted-foreground mt-2">
          Bulwark Webmail is configured. Sign in to continue.
        </p>
      </div>
      <div className="mt-6 space-y-2">
        <a
          href="/admin/login"
          className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
        >
          Sign in to admin dashboard
        </a>
        <a
          href="/"
          className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
        >
          Open webmail login
        </a>
      </div>
    </CenteredCard>
  );
}

function CenteredCard({ children }: { children: ReactNode }) {
  return (
    <div className="min-h-screen flex items-center justify-center px-4">
      <div className="bg-card border border-border rounded-lg p-6 shadow-sm max-w-md w-full">
        {children}
      </div>
    </div>
  );
}

function ErrorBanner({ error, onDismiss }: { error: string; onDismiss: () => void }) {
  return (
    <div className="mb-4 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3">
      <div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
        <AlertCircle className="w-5 h-5" />
      </div>
      <div className="flex-1 min-w-0 self-center">
        <p className="text-sm text-destructive leading-relaxed">{friendlyError(error)}</p>
      </div>
      <button
        onClick={onDismiss}
        className="self-center text-xs text-muted-foreground hover:text-foreground underline shrink-0"
        type="button"
      >
        dismiss
      </button>
    </div>
  );
}

/**
 * Translate raw API error strings into user-facing copy. Matches the friendly
 * tone of the JMAP probe cards.
 */
function friendlyError(raw: string): string {
  const lower = raw.toLowerCase();
  if (lower.includes('invalid or expired token')) {
    return "That setup token isn't valid anymore. Restart the container to get a fresh one from the logs.";
  }
  if (lower.includes('wizard session required')) {
    return 'Your wizard session expired. Paste the setup token again to continue.';
  }
  if (lower.includes('token required')) {
    return 'Paste the setup token printed in the container logs to continue.';
  }
  if (lower.includes('setup is not active')) {
    return 'Setup has already finished. Reload to sign in.';
  }
  return raw;
}

// ─── Welcome / token step ────────────────────────────────────────────────

function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmit: (t: string) => Promise<void> }) {
  const [token, setToken] = useState(tokenFromUrl);
  const [submitting, setSubmitting] = useState(false);

  // Auto-submit if token came in via URL.
  useEffect(() => {
    if (tokenFromUrl && !submitting) {
      setSubmitting(true);
      onSubmit(tokenFromUrl).finally(() => setSubmitting(false));
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tokenFromUrl]);

  async function handle(e: FormEvent) {
    e.preventDefault();
    setSubmitting(true);
    try {
      await onSubmit(token.trim());
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handle} className="space-y-4">
      <h2 className="text-lg font-semibold">Welcome</h2>
      <p className="text-sm text-muted-foreground">
        Paste the setup token printed in the container logs to continue. The token expires after 1 hour.
      </p>
      <Field label="Setup token">
        <Input value={token} onChange={(v) => setToken(v)} autoFocus required placeholder="32-byte hex token" />
      </Field>
      <PrimaryButton type="submit" disabled={submitting || !token.trim()}>
        {submitting ? 'Verifying…' : 'Continue'}
      </PrimaryButton>
    </form>
  );
}

// ─── Step router ─────────────────────────────────────────────────────────

interface StepProps {
  stepIndex: number;
  config: WizardConfig;
  setConfig: React.Dispatch<React.SetStateAction<WizardConfig>>;
  onNext: (step: string, values: Record<string, unknown>) => Promise<void>;
  onBack: () => void;
  onFinish: () => void;
}

function StepContent({ stepIndex, config, setConfig, onNext, onBack, onFinish }: StepProps) {
  switch (stepIndex) {
    case 1:
      return <ServerStep config={config} setConfig={setConfig} onNext={onNext} />;
    case 2:
      return <AuthStep config={config} setConfig={setConfig} onNext={onNext} onBack={onBack} />;
    case 3:
      return <SecurityStep config={config} setConfig={setConfig} onNext={onNext} onBack={onBack} />;
    case 4:
      return <LoggingStep config={config} setConfig={setConfig} onNext={onNext} onBack={onBack} />;
    case 5:
      return <BrandingStep config={config} setConfig={setConfig} onNext={onNext} onBack={onBack} />;
    case 6:
      return <ReviewStep config={config} onBack={onBack} onFinish={onFinish} />;
    default:
      return <p className="text-sm text-muted-foreground">Loading…</p>;
  }
}

// ─── Server step ─────────────────────────────────────────────────────────

type ProbeStatus = 'jmap_detected' | 'reachable_no_jmap' | 'unreachable' | 'invalid_url';

function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 'setConfig' | 'onNext'>) {
  const [submitting, setSubmitting] = useState(false);
  const [probe, setProbe] = useState<{ status: ProbeStatus; message: string; url: string } | null>(null);
  const [probing, setProbing] = useState(false);
  // When the server is reachable but isn't a JMAP endpoint, the wizard
  // shows a "looks wrong, are you sure?" inline confirmation. The flag
  // resets every time the URL changes.
  const [confirmedNonJmap, setConfirmedNonJmap] = useState(false);

  async function testJmap(): Promise<{ status: ProbeStatus; message: string; url: string } | null> {
    setProbe(null);
    setProbing(true);
    setConfirmedNonJmap(false);
    try {
      const res = await apiFetch('/api/setup/test-jmap', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url: config.jmapServerUrl }),
      });
      const data = await res.json();
      let entry: { status: ProbeStatus; message: string; url: string };
      if (data.status === 'jmap_detected') {
        entry = { status: 'jmap_detected', message: 'Connected - this looks like a JMAP server.', url: config.jmapServerUrl };
      } else if (data.status === 'reachable_no_jmap') {
        entry = { status: 'reachable_no_jmap', message: "We reached the server, but it doesn't look like a JMAP endpoint.", url: config.jmapServerUrl };
      } else if (data.status === 'invalid_url') {
        entry = { status: 'invalid_url', message: data.message ?? 'That URL is not valid. Make sure it starts with http:// or https://.', url: config.jmapServerUrl };
      } else {
        entry = { status: 'unreachable', message: data.message ?? "Couldn't connect to that address. Double-check the URL and that the server is online.", url: config.jmapServerUrl };
      }
      setProbe(entry);
      return entry;
    } catch (e) {
      const entry = { status: 'unreachable' as ProbeStatus, message: humanError(e), url: config.jmapServerUrl };
      setProbe(entry);
      return entry;
    } finally {
      setProbing(false);
    }
  }

  const [showAdditional, setShowAdditional] = useState(config.jmapServers.length > 0);

  function updateRow(index: number, patch: Partial<JmapServerRow>) {
    setConfig({
      ...config,
      jmapServers: config.jmapServers.map((row, i) => (i === index ? { ...row, ...patch } : row)),
    });
  }

  function addRow() {
    setConfig({
      ...config,
      jmapServers: [...config.jmapServers, { id: '', label: '', url: '', domains: '' }],
    });
    setShowAdditional(true);
  }

  function removeRow(index: number) {
    setConfig({
      ...config,
      jmapServers: config.jmapServers.filter((_, i) => i !== index),
    });
  }

  // Validate the multi-server rows: each must have a unique id matching the
  // schema, a usable URL, and no collision with the primary server.
  const rowErrors: string[] = [];
  const seenIds = new Set<string>();
  for (let i = 0; i < config.jmapServers.length; i++) {
    const r = config.jmapServers[i];
    const id = r.id.trim();
    if (!id) {
      rowErrors.push(`Server #${i + 1}: id is required`);
    } else if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) {
      rowErrors.push(`Server #${i + 1}: id must be alphanumeric (with - or _), starting with a letter or digit`);
    } else if (seenIds.has(id)) {
      rowErrors.push(`Server #${i + 1}: id "${id}" is duplicated`);
    } else {
      seenIds.add(id);
    }
    const url = r.url.trim();
    if (!url) {
      rowErrors.push(`Server #${i + 1}: url is required`);
    } else if (!/^https?:\/\//i.test(url)) {
      rowErrors.push(`Server #${i + 1}: url must start with http:// or https://`);
    }
  }
  const hasRowErrors = rowErrors.length > 0;

  async function handle(e: FormEvent) {
    e.preventDefault();
    if (hasRowErrors) return;
    setSubmitting(true);
    try {
      // Auto-probe the URL on Next so the operator can't accidentally
      // skip past a wrong URL. If the URL changed since the last probe,
      // re-run; otherwise reuse the cached result.
      let result = probe && probe.url === config.jmapServerUrl ? probe : null;
      if (!result) {
        result = await testJmap();
      }
      if (!result) return;

      // Hard-fail on these - no "are you sure" since they can't be right.
      if (result.status === 'invalid_url' || result.status === 'unreachable') {
        return;
      }

      // Soft warning: server responded but it's not a JMAP endpoint at the
      // standard paths. Could be legitimate (reverse proxy routing) so we
      // ask for explicit confirmation rather than blocking.
      if (result.status === 'reachable_no_jmap' && !confirmedNonJmap) {
        return;
      }

      await onNext('server', {
        appName: config.appName,
        jmapServerUrl: config.jmapServerUrl,
        stalwartFeaturesEnabled: config.stalwartFeaturesEnabled,
        // The API route runs parseJmapServers on this; we pre-canonicalize
        // here so the round-trip is clean.
        jmapServers: rowsToCanonical(config.jmapServers),
        jmapServerAutoPickByDomain: config.jmapServerAutoPickByDomain,
      });
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handle} className="space-y-4">
      <StepHeader title="Server" subtitle="Where your mail lives." />
      <Field label="Application name">
        <Input value={config.appName} onChange={(v) => setConfig({ ...config, appName: v })} required />
      </Field>
      <Field label="JMAP server URL" hint="The default server users connect to. Example: https://mail.example.com">
        <div className="flex gap-2">
          <Input
            value={config.jmapServerUrl}
            onChange={(v) => {
              setConfig({ ...config, jmapServerUrl: v });
              // Any URL change invalidates the previous probe result.
              if (probe && probe.url !== v) {
                setProbe(null);
                setConfirmedNonJmap(false);
              }
            }}
            required
            placeholder="https://"
            type="url"
          />
          <button
            type="button"
            onClick={() => { void testJmap(); }}
            disabled={!config.jmapServerUrl || probing}
            className="px-3 py-2 text-sm border border-border rounded-md hover:bg-muted disabled:opacity-50"
          >
            {probing ? 'Testing…' : 'Test'}
          </button>
        </div>
        {isInsecureHttpUrl(config.jmapServerUrl) && (
          <div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
            <div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
              <AlertTriangle className="w-5 h-5" />
            </div>
            <div className="flex-1 min-w-0 self-center">
              <p className="text-sm font-medium text-foreground leading-relaxed">
                This URL uses plain HTTP.
              </p>
              <p className="text-sm text-muted-foreground mt-0.5 leading-relaxed">
                Passwords and email contents will travel unencrypted between users and your server. Use <code className="font-mono text-xs">https://</code> in production - terminate TLS on the mail server or a reverse proxy in front of it.
              </p>
            </div>
          </div>
        )}
        {probe && probe.url === config.jmapServerUrl && (
          probe.status === 'jmap_detected' ? (
            <div className="mt-2 p-3 rounded-xl border border-success/20 bg-success/5 flex items-start gap-3">
              <div className="w-10 h-10 rounded-full bg-success/15 text-success flex items-center justify-center flex-shrink-0 shadow-sm">
                <CheckCircle2 className="w-5 h-5" />
              </div>
              <div className="flex-1 min-w-0 self-center">
                <p className="text-sm text-foreground leading-relaxed">{probe.message}</p>
              </div>
            </div>
          ) : probe.status === 'reachable_no_jmap' ? (
            <div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5">
              <div className="flex items-start gap-3">
                <div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
                  <AlertTriangle className="w-5 h-5" />
                </div>
                <div className="flex-1 min-w-0 self-center">
                  <p className="text-sm font-medium text-foreground leading-relaxed">{probe.message}</p>
                  <p className="text-sm text-muted-foreground mt-1 leading-relaxed">
                    This can happen when a reverse proxy routes JMAP separately on the same domain. Otherwise, it usually means the URL is wrong.
                  </p>
                </div>
              </div>
              <label className="mt-3 ml-[3.25rem] flex items-center gap-2 cursor-pointer">
                <input
                  type="checkbox"
                  checked={confirmedNonJmap}
                  onChange={(e) => setConfirmedNonJmap(e.target.checked)}
                  className="h-4 w-4"
                />
                <span className="text-sm text-foreground">I&apos;m sure this is the right URL - continue anyway.</span>
              </label>
            </div>
          ) : (
            <div className="mt-2 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3">
              <div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
                <AlertCircle className="w-5 h-5" />
              </div>
              <div className="flex-1 min-w-0 self-center">
                <p className="text-sm text-destructive leading-relaxed">{probe.message}</p>
              </div>
            </div>
          )
        )}
      </Field>

      {/* Additional servers (optional) */}
      <div className="rounded-md border border-border bg-muted/20 p-3">
        <div className="flex items-center justify-between gap-2">
          <div>
            <div className="text-sm font-medium">Additional JMAP servers</div>
            <p className="text-xs text-muted-foreground mt-0.5">
              Optional. Surface multiple servers in the login dropdown - useful for hosts running several Stalwart instances.
            </p>
          </div>
          <button
            type="button"
            onClick={() => setShowAdditional((v) => !v)}
            className="text-xs underline text-muted-foreground hover:text-foreground shrink-0"
          >
            {showAdditional ? 'Hide' : config.jmapServers.length > 0 ? `Show (${config.jmapServers.length})` : 'Add'}
          </button>
        </div>

        {showAdditional && (
          <div className="mt-3 space-y-3">
            {config.jmapServers.map((row, i) => (
              <div key={i} className="rounded-md border border-border bg-background p-3 space-y-2">
                <div className="flex items-center justify-between">
                  <span className="text-xs font-medium text-muted-foreground">Server #{i + 1}</span>
                  <button
                    type="button"
                    onClick={() => removeRow(i)}
                    className="text-xs text-destructive hover:underline"
                  >
                    Remove
                  </button>
                </div>
                <div className="grid grid-cols-2 gap-2">
                  <Field label="ID" hint="Unique slug, e.g. eu-1">
                    <Input value={row.id} onChange={(v) => updateRow(i, { id: v })} placeholder="eu-1" required />
                  </Field>
                  <Field label="Label" hint="Shown to users">
                    <Input value={row.label} onChange={(v) => updateRow(i, { label: v })} placeholder="Europe (primary)" />
                  </Field>
                </div>
                <Field label="URL">
                  <Input value={row.url} onChange={(v) => updateRow(i, { url: v })} placeholder="https://" type="url" required />
                </Field>
                <Field label="Domains" hint="Comma-separated. Used to auto-pick this server by email domain at login.">
                  <Input value={row.domains} onChange={(v) => updateRow(i, { domains: v })} placeholder="example.com, mail.example.com" />
                </Field>
              </div>
            ))}

            <button
              type="button"
              onClick={addRow}
              className="w-full px-3 py-2 text-sm border border-dashed border-border rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
            >
              + Add server
            </button>

            {config.jmapServers.length > 0 && (
              <Toggle
                checked={config.jmapServerAutoPickByDomain}
                onChange={(v) => setConfig({ ...config, jmapServerAutoPickByDomain: v })}
                label="Auto-pick server by email domain"
                hint="When a user types their email, automatically select the matching server from the list above."
              />
            )}

            {hasRowErrors && (
              <ul className="text-xs text-destructive list-disc pl-5 space-y-0.5">
                {rowErrors.map((err, i) => (
                  <li key={i}>{err}</li>
                ))}
              </ul>
            )}
          </div>
        )}
      </div>

      <Toggle
        checked={config.stalwartFeaturesEnabled}
        onChange={(v) => setConfig({ ...config, stalwartFeaturesEnabled: v })}
        label="Enable Stalwart-specific features"
        hint="Adds password change and Sieve filter management. Safe to enable on non-Stalwart servers."
      />

      <Footer>
        <PrimaryButton
          type="submit"
          disabled={
            submitting ||
            !config.jmapServerUrl ||
            hasRowErrors ||
            // Once probed, gate the button on the result so the user gets a
            // visual signal rather than a silent no-op when clicking Next.
            (probe?.url === config.jmapServerUrl &&
              ((probe.status === 'reachable_no_jmap' && !confirmedNonJmap) ||
                probe.status === 'invalid_url' ||
                probe.status === 'unreachable'))
          }
        >
          {submitting ? 'Saving…' : probing ? 'Testing…' : 'Next'}
        </PrimaryButton>
      </Footer>
    </form>
  );
}

// ─── Auth step ───────────────────────────────────────────────────────────

function AuthStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'config' | 'setConfig' | 'onNext' | 'onBack'>) {
  const [submitting, setSubmitting] = useState(false);

  async function handle(e: FormEvent) {
    e.preventDefault();
    setSubmitting(true);
    try {
      const values: Partial<WizardConfig> = { oauthEnabled: config.oauthEnabled };
      if (config.oauthEnabled) {
        values.oauthOnly = config.oauthOnly;
        values.oauthClientId = config.oauthClientId;
        values.oauthIssuerUrl = config.oauthIssuerUrl;
        if (config.oauthClientSecret) {
          values.oauthClientSecret = config.oauthClientSecret;
        }
      }
      await onNext('auth', values);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handle} className="space-y-4">
      <StepHeader title="Authentication" subtitle="Basic auth is always available. OAuth/OIDC is optional." />
      <Toggle
        checked={config.oauthEnabled}
        onChange={(v) => setConfig({ ...config, oauthEnabled: v })}
        label="Enable OAuth2 / OpenID Connect"
      />
      {config.oauthEnabled && (
        <>
          <Toggle
            checked={config.oauthOnly}
            onChange={(v) => setConfig({ ...config, oauthOnly: v })}
            label="OAuth-only mode (hide password form)"
          />
          <Field label="OAuth Client ID">
            <Input value={config.oauthClientId} onChange={(v) => setConfig({ ...config, oauthClientId: v })} required />
          </Field>
          <Field label="OAuth Client Secret" hint="Leave blank for public clients using PKCE only.">
            <Input
              value={config.oauthClientSecret}
              onChange={(v) => setConfig({ ...config, oauthClientSecret: v })}
              type="password"
              placeholder="paste secret"
            />
          </Field>
          <Field label="OAuth Issuer URL" hint="For external IdPs (Keycloak, Authentik, Entra ID, etc.).">
            <Input value={config.oauthIssuerUrl} onChange={(v) => setConfig({ ...config, oauthIssuerUrl: v })} type="url" />
          </Field>
        </>
      )}
      <Footer>
        <SecondaryButton onClick={onBack}>Back</SecondaryButton>
        <PrimaryButton type="submit" disabled={submitting}>
          {submitting ? 'Saving…' : 'Next'}
        </PrimaryButton>
      </Footer>
    </form>
  );
}

// ─── Security step ───────────────────────────────────────────────────────

function generateSessionSecret(): string {
  // 32 random bytes, base64-encoded - same shape as `openssl rand -base64 32`.
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin);
}

function SecurityStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'config' | 'setConfig' | 'onNext' | 'onBack'>) {
  const [submitting, setSubmitting] = useState(false);
  const [reveal, setReveal] = useState(false);
  const [customize, setCustomize] = useState(false);

  // Auto-generate on first render so the operator doesn't have to click a
  // button for the recommended path. They can still regenerate or paste
  // their own via the "Customize" toggle.
  useEffect(() => {
    if (!config.sessionSecret) {
      setConfig((prev) => ({ ...prev, sessionSecret: generateSessionSecret() }));
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  async function handle(e: FormEvent) {
    e.preventDefault();
    setSubmitting(true);
    try {
      const values: Partial<WizardConfig> = {
        settingsSyncEnabled: config.settingsSyncEnabled,
      };
      if (config.sessionSecret) values.sessionSecret = config.sessionSecret;
      await onNext('security', values);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handle} className="space-y-4">
      <StepHeader
        title="Security & Sessions"
        subtitle='Session secret unlocks "Remember me" and settings sync.'
      />

      <div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-sm">
        <div className="flex items-center justify-between gap-2">
          <span className="font-medium">Session secret generated</span>
          <button
            type="button"
            onClick={() => setCustomize((v) => !v)}
            className="text-xs underline text-muted-foreground hover:text-foreground"
          >
            {customize ? 'Hide' : 'Customize'}
          </button>
        </div>
        <p className="text-xs text-muted-foreground mt-0.5">
          A 32-byte secret was created for you. You only need to change this if you have a specific reason.
        </p>
      </div>

      {customize && (
        <Field label="Session secret" hint="Paste your own value or click regenerate.">
          <div className="flex gap-2">
            <Input
              value={config.sessionSecret}
              onChange={(v) => setConfig({ ...config, sessionSecret: v })}
              type={reveal ? 'text' : 'password'}
            />
            <button
              type="button"
              onClick={() => setReveal((v) => !v)}
              className="px-3 py-2 text-sm border border-border rounded-md hover:bg-muted"
            >
              {reveal ? 'Hide' : 'Show'}
            </button>
            <button
              type="button"
              onClick={() => setConfig({ ...config, sessionSecret: generateSessionSecret() })}
              className="px-3 py-2 text-sm border border-border rounded-md hover:bg-muted"
            >
              Regenerate
            </button>
          </div>
        </Field>
      )}

      <Toggle
        checked={config.settingsSyncEnabled}
        onChange={(v) => setConfig({ ...config, settingsSyncEnabled: v })}
        label="Sync user settings across devices"
        hint="Stores user preferences server-side, encrypted with the session secret."
        disabled={!config.sessionSecret}
      />
      <Footer>
        <SecondaryButton onClick={onBack}>Back</SecondaryButton>
        <PrimaryButton type="submit" disabled={submitting}>
          {submitting ? 'Saving…' : 'Next'}
        </PrimaryButton>
      </Footer>
    </form>
  );
}

// ─── Logging step ────────────────────────────────────────────────────────

function LoggingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'config' | 'setConfig' | 'onNext' | 'onBack'>) {
  const [submitting, setSubmitting] = useState(false);
  async function handle(e: FormEvent) {
    e.preventDefault();
    setSubmitting(true);
    try {
      await onNext('logging', { logFormat: config.logFormat, logLevel: config.logLevel });
    } finally {
      setSubmitting(false);
    }
  }
  return (
    <form onSubmit={handle} className="space-y-4">
      <StepHeader title="Logging" subtitle="Format and verbosity for the application logs." />
      <Field label="Format">
        <Select
          value={config.logFormat}
          onChange={(v) => setConfig({ ...config, logFormat: v as 'text' | 'json' })}
          options={[
            { value: 'text', label: 'text - colored, human-readable' },
            { value: 'json', label: 'json - structured, for log aggregation' },
          ]}
        />
      </Field>
      <Field label="Level">
        <Select
          value={config.logLevel}
          onChange={(v) => setConfig({ ...config, logLevel: v as WizardConfig['logLevel'] })}
          options={[
            { value: 'error', label: 'error' },
            { value: 'warn', label: 'warn' },
            { value: 'info', label: 'info (recommended)' },
            { value: 'debug', label: 'debug' },
          ]}
        />
      </Field>
      <Footer>
        <SecondaryButton onClick={onBack}>Back</SecondaryButton>
        <PrimaryButton type="submit" disabled={submitting}>
          {submitting ? 'Saving…' : 'Next'}
        </PrimaryButton>
      </Footer>
    </form>
  );
}

// ─── Branding step ───────────────────────────────────────────────────────

type BrandingSlot =
  | 'faviconUrl'
  | 'appLogoLightUrl'
  | 'appLogoDarkUrl'
  | 'loginLogoLightUrl'
  | 'loginLogoDarkUrl';

function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'config' | 'setConfig' | 'onNext' | 'onBack'>) {
  const [submitting, setSubmitting] = useState(false);

  async function handle(e: FormEvent) {
    e.preventDefault();
    setSubmitting(true);
    try {
      // Only send fields with a value. Empty strings would create an admin
      // override that shadows the system default and suppress the bundled
      // Bulwark logo on the login page.
      const allFields = {
        faviconUrl: config.faviconUrl,
        appLogoLightUrl: config.appLogoLightUrl,
        appLogoDarkUrl: config.appLogoDarkUrl,
        loginLogoLightUrl: config.loginLogoLightUrl,
        loginLogoDarkUrl: config.loginLogoDarkUrl,
        loginCompanyName: config.loginCompanyName,
        loginImprintUrl: config.loginImprintUrl,
        loginPrivacyPolicyUrl: config.loginPrivacyPolicyUrl,
        loginWebsiteUrl: config.loginWebsiteUrl,
      };
      const values: Record<string, string> = {};
      for (const [k, v] of Object.entries(allFields)) {
        if (v.trim() !== '') values[k] = v.trim();
      }
      await onNext('branding', values);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handle} className="space-y-4">
      <StepHeader
        title="Branding"
        subtitle="All fields optional. Upload a file or paste a URL - defaults are used for anything you skip."
      />
      <Field label="Company / organization name">
        <Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
      </Field>

      <div className="space-y-2">
        <BrandingAsset
          label="Favicon"
          hint="Browser tab icon. SVG recommended."
          slot="faviconUrl"
          value={config.faviconUrl}
          onChange={(v) => setConfig({ ...config, faviconUrl: v })}
        />
        <BrandingAsset
          label="Login logo (light mode)"
          hint="Shown on the sign-in page, light backgrounds."
          slot="loginLogoLightUrl"
          value={config.loginLogoLightUrl}
          onChange={(v) => setConfig({ ...config, loginLogoLightUrl: v })}
        />
        <BrandingAsset
          label="Login logo (dark mode)"
          hint="Shown on the sign-in page, dark backgrounds."
          slot="loginLogoDarkUrl"
          value={config.loginLogoDarkUrl}
          onChange={(v) => setConfig({ ...config, loginLogoDarkUrl: v })}
          previewBg="dark"
        />
        <BrandingAsset
          label="Sidebar logo (light mode)"
          hint="Shown after sign-in. Leave blank for none."
          slot="appLogoLightUrl"
          value={config.appLogoLightUrl}
          onChange={(v) => setConfig({ ...config, appLogoLightUrl: v })}
        />
        <BrandingAsset
          label="Sidebar logo (dark mode)"
          hint="Dark mode variant of the sidebar logo."
          slot="appLogoDarkUrl"
          value={config.appLogoDarkUrl}
          onChange={(v) => setConfig({ ...config, appLogoDarkUrl: v })}
          previewBg="dark"
        />
      </div>

      <Field label="Website URL">
        <Input value={config.loginWebsiteUrl} onChange={(v) => setConfig({ ...config, loginWebsiteUrl: v })} type="url" />
      </Field>
      <Field label="Imprint URL">
        <Input value={config.loginImprintUrl} onChange={(v) => setConfig({ ...config, loginImprintUrl: v })} type="url" />
      </Field>
      <Field label="Privacy policy URL">
        <Input value={config.loginPrivacyPolicyUrl} onChange={(v) => setConfig({ ...config, loginPrivacyPolicyUrl: v })} type="url" />
      </Field>
      <Footer>
        <SecondaryButton onClick={onBack}>Back</SecondaryButton>
        <PrimaryButton type="submit" disabled={submitting}>
          {submitting ? 'Saving…' : 'Next'}
        </PrimaryButton>
      </Footer>
    </form>
  );
}

/**
 * One branding asset slot: shows a thumbnail preview if a value is set,
 * a file picker (uploads to /api/setup/branding), and a URL field for
 * operators who'd rather paste a link. Upload and URL are mutually
 * compatible - the URL field always reflects the persisted value.
 */
function BrandingAsset({
  label,
  hint,
  slot,
  value,
  onChange,
  previewBg = 'light',
}: {
  label: string;
  hint?: string;
  slot: BrandingSlot;
  value: string;
  onChange: (v: string) => void;
  previewBg?: 'light' | 'dark';
}) {
  const [uploading, setUploading] = useState(false);
  const [uploadError, setUploadError] = useState<string | null>(null);
  const [showUrlField, setShowUrlField] = useState(false);
  const [dragOver, setDragOver] = useState(false);

  async function handleFile(file: File) {
    setUploadError(null);
    setUploading(true);
    try {
      const fd = new FormData();
      fd.append('file', file);
      fd.append('slot', slot);
      const res = await apiFetch('/api/setup/branding', {
        method: 'POST',
        body: fd,
      });
      const data = await res.json();
      if (!res.ok) {
        setUploadError(data?.error ?? `Upload failed (HTTP ${res.status})`);
        return;
      }
      onChange(data.url);
    } catch (e) {
      setUploadError(humanError(e));
    } finally {
      setUploading(false);
    }
  }

  async function clearAsset() {
    setUploadError(null);
    try {
      await apiFetch('/api/setup/branding', {
        method: 'DELETE',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ slot }),
      }).catch(() => null);
    } finally {
      onChange('');
    }
  }

  const previewClasses =
    'shrink-0 w-16 h-16 rounded-md border border-border flex items-center justify-center overflow-hidden transition-colors ' +
    (previewBg === 'dark' ? 'bg-zinc-900' : 'bg-muted/40') +
    (dragOver ? ' ring-2 ring-primary border-primary' : '');

  return (
    <div className="rounded-lg border border-border bg-card/50 p-3">
      <div className="flex items-center gap-3">
        <label
          className={previewClasses + (uploading ? ' opacity-50' : ' cursor-pointer hover:border-foreground/30')}
          onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
          onDragLeave={() => setDragOver(false)}
          onDrop={(e) => {
            e.preventDefault();
            setDragOver(false);
            const f = e.dataTransfer.files?.[0];
            if (f) void handleFile(f);
          }}
        >
          <input
            type="file"
            accept="image/svg+xml,image/png,image/jpeg,image/webp,image/x-icon,image/vnd.microsoft.icon"
            disabled={uploading}
            className="sr-only"
            onChange={(e) => {
              const f = e.target.files?.[0];
              if (f) void handleFile(f);
              e.target.value = '';
            }}
          />
          {value ? (
            <img src={value} alt="" className="max-w-full max-h-full object-contain" />
          ) : (
            <span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
          )}
        </label>
        <div className="flex-1 min-w-0">
          <div className="flex items-baseline justify-between gap-2">
            <div className="text-sm font-medium truncate">{label}</div>
            {value && (
              <button
                type="button"
                onClick={clearAsset}
                className="text-xs text-muted-foreground hover:text-destructive shrink-0"
              >
                Remove
              </button>
            )}
          </div>
          {hint && <p className="text-xs text-muted-foreground mt-0.5">{hint}</p>}
          <div className="mt-1.5 flex items-center gap-2 text-xs">
            {uploading ? (
              <span className="text-muted-foreground">Uploading…</span>
            ) : value ? (
              <span className="text-muted-foreground truncate">
                {value.startsWith('/api/') ? 'Uploaded file' : value}
              </span>
            ) : (
              <span className="text-muted-foreground">SVG, PNG, JPEG, WebP or ICO · max 2 MB</span>
            )}
            <button
              type="button"
              onClick={() => setShowUrlField((v) => !v)}
              className="text-muted-foreground hover:text-foreground underline shrink-0"
            >
              {showUrlField ? 'Hide URL' : 'Use URL'}
            </button>
          </div>
        </div>
      </div>

      {showUrlField && (
        <div className="mt-3 pl-[4.75rem]">
          <Input
            value={value}
            onChange={onChange}
            placeholder="https://… or /branding/file.svg"
          />
        </div>
      )}

      {uploadError && (
        <p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
      )}
    </div>
  );
}

// ─── Review / finish step ─────────────────────────────────────────────────

function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) {
  const [adminPassword, setAdminPassword] = useState('');
  const [adminConfirm, setAdminConfirm] = useState('');
  const [lockConfig, setLockConfig] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [localError, setLocalError] = useState<string | null>(null);

  async function handle(e: FormEvent) {
    e.preventDefault();
    setLocalError(null);
    if (adminPassword.length < 8) {
      setLocalError('Admin password must be at least 8 characters.');
      return;
    }
    if (adminPassword !== adminConfirm) {
      setLocalError('Passwords do not match.');
      return;
    }
    setSubmitting(true);
    try {
      const res = await apiFetch('/api/setup/finish', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ adminPassword, lockConfig }),
      });
      if (!res.ok) {
        const data = await res.json().catch(() => ({}));
        setLocalError(data.error ?? `Finish failed (HTTP ${res.status})`);
        return;
      }
      onFinish();
    } catch (e) {
      setLocalError(humanError(e));
    } finally {
      setSubmitting(false);
    }
  }

  const passwordsMatch = adminConfirm.length > 0 && adminPassword === adminConfirm;
  const passwordTooShort = adminPassword.length > 0 && adminPassword.length < 8;
  const canSubmit =
    !submitting &&
    adminPassword.length >= 8 &&
    passwordsMatch;

  return (
    <form onSubmit={handle} className="space-y-5">
      <StepHeader title="Almost done" subtitle="Review your settings, choose an admin password, and apply." />

      {/* Summary card with grouped sections */}
      <div className="rounded-lg border border-border divide-y divide-border overflow-hidden">
        <SummaryGroup icon={<Server className="w-4 h-4" />} title="Server">
          <SummaryRow label="App name" value={config.appName} />
          <SummaryRow label="JMAP server" value={config.jmapServerUrl} mono />
          {config.jmapServers.length > 0 && (
            <SummaryRow
              label="Additional servers"
              value={`${config.jmapServers.length} configured${config.jmapServerAutoPickByDomain ? ' · auto-pick by domain' : ''}`}
            />
          )}
          <SummaryRow label="Stalwart features" value={config.stalwartFeaturesEnabled ? 'On' : 'Off'} />
        </SummaryGroup>

        <SummaryGroup icon={<ShieldCheck className="w-4 h-4" />} title="Authentication">
          <SummaryRow
            label="Method"
            value={
              config.oauthEnabled
                ? config.oauthOnly
                  ? 'OAuth only'
                  : 'Password + OAuth'
                : 'Password only'
            }
          />
          {config.oauthEnabled && config.oauthClientId && (
            <SummaryRow label="OAuth client ID" value={config.oauthClientId} mono />
          )}
        </SummaryGroup>

        <SummaryGroup icon={<KeyRound className="w-4 h-4" />} title="Security">
          <SummaryRow label="Session secret" value={config.sessionSecret ? 'Configured' : 'Not set'} />
          <SummaryRow label="Remember me" value={config.sessionSecret ? 'Available' : 'Disabled'} />
          <SummaryRow
            label="Settings sync"
            value={
              config.sessionSecret && config.settingsSyncEnabled
                ? 'On'
                : config.settingsSyncEnabled
                  ? 'Requires session secret'
                  : 'Off'
            }
          />
        </SummaryGroup>

        <SummaryGroup icon={<FileText className="w-4 h-4" />} title="Logging">
          <SummaryRow label="Format" value={config.logFormat} />
          <SummaryRow label="Level" value={config.logLevel} />
        </SummaryGroup>

        <SummaryGroup icon={<Palette className="w-4 h-4" />} title="Branding">
          <SummaryRow
            label="Customizations"
            value={hasAnyBranding(config) ? 'Custom assets configured' : 'Using defaults'}
          />
          {config.loginCompanyName && (
            <SummaryRow label="Company name" value={config.loginCompanyName} />
          )}
        </SummaryGroup>
      </div>

      {/* Admin password card */}
      <div className="rounded-lg border border-border bg-card p-4">
        <div className="flex items-start gap-3 mb-3">
          <div className="w-10 h-10 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
            <Lock className="w-5 h-5" />
          </div>
          <div className="flex-1 min-w-0">
            <div className="text-sm font-medium">Choose an admin password</div>
            <p className="text-xs text-muted-foreground mt-0.5">
              You&apos;ll use this to sign in at <code className="font-mono">/admin</code>. Minimum 8 characters.
            </p>
          </div>
        </div>
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
          <div>
            <label className="block text-xs text-muted-foreground mb-1">New password</label>
            <Input value={adminPassword} onChange={setAdminPassword} type="password" required />
            {passwordTooShort && (
              <p className="text-xs text-warning mt-1">At least 8 characters.</p>
            )}
          </div>
          <div>
            <label className="block text-xs text-muted-foreground mb-1">Confirm</label>
            <Input value={adminConfirm} onChange={setAdminConfirm} type="password" required />
            {adminConfirm.length > 0 && !passwordsMatch && (
              <p className="text-xs text-destructive mt-1">Passwords don&apos;t match.</p>
            )}
            {passwordsMatch && adminPassword.length >= 8 && (
              <p className="text-xs text-success mt-1">Looks good.</p>
            )}
          </div>
        </div>
      </div>

      {/* Advanced */}
      <details className="rounded-lg border border-border bg-card/50 p-3 group">
        <summary className="text-sm font-medium cursor-pointer flex items-center justify-between list-none [&::-webkit-details-marker]:hidden">
          <span>Advanced</span>
          <span className="text-xs text-muted-foreground group-open:hidden">Show</span>
          <span className="text-xs text-muted-foreground hidden group-open:inline">Hide</span>
        </summary>
        <div className="mt-3 pt-3 border-t border-border">
          <Toggle
            checked={lockConfig}
            onChange={setLockConfig}
            label="Lock configuration after setup"
            hint="Drops a marker file. After this finishes, remount the config volume read-only and the app will refuse further config writes. Audit logs and login state stay writable in the state volume."
          />
        </div>
      </details>

      {localError && (
        <div className="p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3">
          <div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
            <AlertCircle className="w-5 h-5" />
          </div>
          <div className="flex-1 min-w-0 self-center">
            <p className="text-sm text-destructive leading-relaxed">{localError}</p>
          </div>
        </div>
      )}

      <Footer>
        <SecondaryButton onClick={onBack} disabled={submitting}>Back</SecondaryButton>
        <PrimaryButton type="submit" disabled={!canSubmit}>
          {submitting ? 'Applying…' : 'Apply & Finish'}
        </PrimaryButton>
      </Footer>
    </form>
  );
}

function SummaryGroup({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
  return (
    <div className="bg-card/50">
      <div className="flex items-center gap-2 px-3 py-2 bg-muted/40">
        <span className="text-muted-foreground">{icon}</span>
        <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{title}</span>
      </div>
      <div className="px-3 py-2 space-y-1.5">{children}</div>
    </div>
  );
}

function SummaryRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
  return (
    <div className="flex justify-between items-baseline gap-3 text-sm">
      <span className="text-muted-foreground shrink-0">{label}</span>
      <span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
        {value || <span className="text-muted-foreground italic">-</span>}
      </span>
    </div>
  );
}

// ─── Atoms ────────────────────────────────────────────────────────────────

function StepHeader({ title, subtitle }: { title: string; subtitle?: string }) {
  return (
    <div className="border-b border-border pb-3 mb-2">
      <h2 className="text-lg font-semibold">{title}</h2>
      {subtitle && <p className="text-sm text-muted-foreground mt-0.5">{subtitle}</p>}
    </div>
  );
}

function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
  return (
    <div>
      <label className="block text-sm font-medium mb-1">{label}</label>
      {children}
      {hint && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
    </div>
  );
}

function Input({
  value,
  onChange,
  type = 'text',
  placeholder,
  required,
  autoFocus,
}: {
  value: string;
  onChange: (v: string) => void;
  type?: string;
  placeholder?: string;
  required?: boolean;
  autoFocus?: boolean;
}) {
  return (
    <input
      type={type}
      value={value}
      onChange={(e) => onChange(e.target.value)}
      placeholder={placeholder}
      required={required}
      autoFocus={autoFocus}
      className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
    />
  );
}

function Select({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) {
  return (
    <select
      value={value}
      onChange={(e) => onChange(e.target.value)}
      className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
    >
      {options.map((o) => (
        <option key={o.value} value={o.value}>
          {o.label}
        </option>
      ))}
    </select>
  );
}

function Toggle({
  checked,
  onChange,
  label,
  hint,
  disabled,
}: {
  checked: boolean;
  onChange: (v: boolean) => void;
  label: string;
  hint?: string;
  disabled?: boolean;
}) {
  return (
    <label className={'flex items-start gap-3 cursor-pointer ' + (disabled ? 'opacity-50 cursor-not-allowed' : '')}>
      <input
        type="checkbox"
        checked={checked}
        disabled={disabled}
        onChange={(e) => onChange(e.target.checked)}
        className="mt-1 h-4 w-4"
      />
      <div>
        <div className="text-sm font-medium">{label}</div>
        {hint && <div className="text-xs text-muted-foreground mt-0.5">{hint}</div>}
      </div>
    </label>
  );
}

function Footer({ children }: { children: ReactNode }) {
  return <div className="flex justify-end gap-2 pt-3 border-t border-border mt-4">{children}</div>;
}

function PrimaryButton({ children, ...rest }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return (
    <button
      {...rest}
      className="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {children}
    </button>
  );
}

function SecondaryButton({ children, onClick, disabled }: { children: ReactNode; onClick: () => void; disabled?: boolean }) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      className="inline-flex items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
    >
      {children}
    </button>
  );
}

// ─── Helpers ──────────────────────────────────────────────────────────────

function mergePartial(prev: WizardConfig, partial: Record<string, unknown>): WizardConfig {
  const next: WizardConfig = { ...prev };
  for (const key of Object.keys(prev) as (keyof WizardConfig)[]) {
    const incoming = partial[key];
    if (incoming === undefined) continue;
    if (key === 'jmapServers') {
      // Server stores canonical shape; wizard form uses csv domains string.
      next.jmapServers = canonicalToRows(incoming);
      continue;
    }
    if (typeof incoming === typeof prev[key] || prev[key] === '' || prev[key] === false) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (next as any)[key] = incoming;
    }
  }
  return next;
}

function canonicalToRows(value: unknown): JmapServerRow[] {
  if (!Array.isArray(value)) return [];
  return value
    .map((item): JmapServerRow | null => {
      if (!item || typeof item !== 'object') return null;
      const e = item as Record<string, unknown>;
      const id = typeof e.id === 'string' ? e.id : '';
      const label = typeof e.label === 'string' ? e.label : '';
      const url = typeof e.url === 'string' ? e.url : '';
      const domains = Array.isArray(e.domains)
        ? (e.domains as unknown[])
            .filter((d): d is string => typeof d === 'string')
            .join(', ')
        : '';
      if (!id || !url) return null;
      return { id, label, url, domains };
    })
    .filter((r): r is JmapServerRow => r !== null);
}

function rowsToCanonical(rows: JmapServerRow[]) {
  return rows
    .map((r) => {
      const id = r.id.trim();
      const url = r.url.trim();
      if (!id || !url) return null;
      const domains = r.domains
        .split(',')
        .map((d) => d.trim())
        .filter(Boolean);
      return {
        id,
        label: r.label.trim() || id,
        url,
        ...(domains.length > 0 ? { domains } : {}),
      };
    })
    .filter((e): e is { id: string; label: string; url: string; domains?: string[] } => e !== null);
}

function hasAnyBranding(c: WizardConfig): boolean {
  return Boolean(
    c.loginCompanyName ||
      c.faviconUrl ||
      c.appLogoLightUrl ||
      c.appLogoDarkUrl ||
      c.loginLogoLightUrl ||
      c.loginLogoDarkUrl ||
      c.loginWebsiteUrl ||
      c.loginImprintUrl ||
      c.loginPrivacyPolicyUrl,
  );
}

function isInsecureHttpUrl(url: string): boolean {
  return /^http:\/\//i.test(url.trim());
}

function detectInsecureContext(): boolean {
  if (typeof window === 'undefined') return false;
  if (window.location.protocol !== 'http:') return false;
  // Browsers treat localhost/loopback as "potentially trustworthy" and accept
  // Secure cookies even without TLS, so the wizard still works there.
  const host = window.location.hostname;
  if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
    return false;
  }
  return true;
}

function humanError(e: unknown): string {
  if (e instanceof Error) return e.message;
  if (typeof e === 'string') return e;
  return 'Unknown error';
}
