import { useEffect, useState } from 'react'
import { Eye, EyeOff, CheckCircle2, XCircle, Loader2, ExternalLink, AlertCircle, RotateCw, Copy, KeyRound, Download, Sparkles, ClipboardPaste, ShieldAlert } from 'lucide-react'
import { useWizardStore } from '@/entities/deployment/store'
import { resolveSovereignDomain, isValidSSHPublicKey, type CloudProvider } from '@/entities/deployment/model'
import { useBreakpoint } from '@/shared/lib/useBreakpoint'
import { API_BASE } from '@/shared/config/urls'
import { StepShell, useStepNav } from './_shared'

type ValidationState = 'idle' | 'validating' | 'valid' | 'invalid'

/**
 * Specific failure mode reported by the validator. Used to render a
 * targeted error UI per docs/INVIOLABLE-PRINCIPLES.md #2 ("never compromise
 * from quality") — generic "rejected" messages cost the user a support
 * ticket; a specific reason lets them self-recover.
 *
 * Closes #123 ([I] ux: error handling — what happens if Hetzner API rejects token).
 */
type FailureKind =
  | 'rejected'      // backend confirmed token is wrong (401/403 path)
  | 'too-short'     // client- or server-side length validation
  | 'unreachable'   // could not reach the cloud provider's API (503 path)
  | 'network'       // could not reach catalyst-api (CORS, offline, DNS)
  | 'parse'         // backend response was malformed
  | 'http'          // any other non-2xx HTTP status

interface FailureDetail {
  kind: FailureKind
  /** Short human-readable summary (one line). */
  summary: string
  /** Detailed remediation hint (multi-line, may include link). */
  hint: string
  /** Raw backend message verbatim, when available. */
  rawMessage?: string
  /** HTTP status code, when relevant. */
  status?: number
}

const FAILURE_HINTS: Record<FailureKind, { summary: string; hint: string }> = {
  rejected: {
    summary: 'Token rejected by Hetzner Cloud',
    hint:
      'The token authenticated but does not have the permissions OpenTofu needs. ' +
      'Generate a new token in Hetzner Cloud Console → Security → API Tokens, ' +
      'pick the same project, and select "Read & Write" — never "Read only".',
  },
  'too-short': {
    summary: 'Token is too short',
    hint:
      'Hetzner API tokens are at least 64 characters long. ' +
      'Make sure you copied the full token from the Hetzner Cloud Console — ' +
      "the token is only shown once at creation time, so if you've lost it, generate a new one.",
  },
  unreachable: {
    summary: 'Could not reach Hetzner Cloud',
    hint:
      'The catalyst-api could not establish a TLS connection to api.hetzner.cloud. ' +
      'This is usually transient — wait a few seconds and retry. ' +
      'If it persists, check the Hetzner status page at status.hetzner.com.',
  },
  network: {
    summary: 'Could not reach the validation service',
    hint:
      'The wizard could not POST to /api/v1/credentials/validate. ' +
      'You may be offline, behind a captive portal, or the catalyst-api is down. ' +
      'Reload the page and try again — the wizard preserves your inputs.',
  },
  parse: {
    summary: 'Validation service returned a malformed response',
    hint:
      "The backend's response could not be parsed as JSON. This is a backend bug — " +
      'open a support ticket with the diagnostic JSON below and the wizard team will investigate.',
  },
  http: {
    summary: 'Validation service returned an unexpected status',
    hint:
      'The validation endpoint returned a non-2xx status that the wizard does not handle. ' +
      'Retry — if it persists, copy the diagnostic and file a support ticket.',
  },
}

const PROVIDER_NAMES: Record<CloudProvider, string> = {
  hetzner: 'Hetzner Cloud',
  huawei:  'Huawei Cloud',
  oci:     'Oracle Cloud (OCI)',
  aws:     'Amazon Web Services',
  azure:   'Microsoft Azure',
}

const PROVIDER_TOKEN_HINT: Record<CloudProvider, string> = {
  hetzner: 'Read & Write API token (64+ chars)',
  huawei:  'Access Key ID — format: AK + secret',
  oci:     'API private key or session token',
  aws:     'Access Key ID + secret (paste as JSON or combined)',
  azure:   'Service principal client ID + secret',
}

/* ── Token section — one per provider ───────────────────────────────── */
function TokenSection({
  provider,
  regionIndices,
}: {
  provider: CloudProvider
  regionIndices: number[]
}) {
  const store = useWizardStore()
  const [token, setToken] = useState(store.providerTokens[provider] ?? '')
  const [show, setShow] = useState(false)
  const [state, setState] = useState<ValidationState>(
    store.providerValidated[provider] ? 'valid' : 'idle'
  )
  /**
   * Specific failure detail — populated when validate() determines the
   * token is invalid OR validation itself failed. Displays a targeted
   * error card with remediation steps + retry button.
   */
  const [failure, setFailure] = useState<FailureDetail | null>(null)
  const [focused, setFocused] = useState(false)

  function handleChange(v: string) {
    setToken(v)
    setFailure(null)
    setState('idle')
    store.setProviderValidated(provider, false)
  }

  /**
   * validate() — POSTs to /api/v1/credentials/validate and surfaces the
   * exact failure mode the backend reports. Replaces the previous
   * silently-swallow-on-error behaviour: per docs/INVIOLABLE-PRINCIPLES.md
   * #1, ANY validation error is a hard "invalid" — the user explicitly
   * needs to know what went wrong, not be told everything is fine when
   * the backend returned 503.
   *
   * Closes #123.
   */
  async function validate() {
    if (token.trim().length < 64) {
      setFailure({
        kind: 'too-short',
        summary: FAILURE_HINTS['too-short'].summary,
        hint: FAILURE_HINTS['too-short'].hint,
      })
      setState('invalid')
      return
    }
    setState('validating')
    setFailure(null)
    store.setProviderToken(provider, token)

    let res: Response
    try {
      res = await fetch(`${API_BASE}/v1/credentials/validate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token, provider }),
      })
    } catch (err) {
      setFailure({
        kind: 'network',
        summary: FAILURE_HINTS.network.summary,
        hint: FAILURE_HINTS.network.hint,
        rawMessage: String(err),
      })
      setState('invalid')
      store.setProviderValidated(provider, false)
      return
    }

    let data: { valid?: boolean; message?: string } | null = null
    try {
      data = (await res.json()) as { valid?: boolean; message?: string }
    } catch (err) {
      setFailure({
        kind: 'parse',
        summary: FAILURE_HINTS.parse.summary,
        hint: FAILURE_HINTS.parse.hint,
        rawMessage: String(err),
        status: res.status,
      })
      setState('invalid')
      store.setProviderValidated(provider, false)
      return
    }

    // Backend wire format (handler/credentials.go):
    //   200 + valid=true   → token good, set state=valid
    //   200 + valid=false  → token rejected by Hetzner
    //   400 + valid=false  → too-short (server-side check)
    //   503 + valid=false  → Hetzner API unreachable
    //   anything else      → unhandled
    if (res.ok && data?.valid === true) {
      setState('valid')
      store.setProviderValidated(provider, true)
      if (provider === 'hetzner') {
        store.setHetznerToken(token)
        store.setCredentialValidated(true)
      }
      return
    }

    let kind: FailureKind = 'rejected'
    if (res.status === 503) kind = 'unreachable'
    else if (res.status === 400) kind = 'too-short'
    else if (!res.ok) kind = 'http'

    setFailure({
      kind,
      summary: FAILURE_HINTS[kind].summary,
      hint: FAILURE_HINTS[kind].hint,
      rawMessage: data?.message,
      status: res.status,
    })
    setState('invalid')
    store.setProviderValidated(provider, false)
  }

  function skipDemo() {
    const demoToken = `demo-mode-${provider}-` + 'x'.repeat(50)
    setToken(demoToken)
    setState('valid')
    store.setProviderToken(provider, demoToken)
    store.setProviderValidated(provider, true)
    if (provider === 'hetzner') {
      store.setHetznerToken(demoToken)
      store.setCredentialValidated(true)
    }
  }

  return (
    <div style={{
      borderRadius: 12, overflow: 'hidden',
      border: state === 'valid'
        ? '1.5px solid rgba(74,222,128,0.3)'
        : '1.5px solid var(--wiz-border-sub)',
      background: state === 'valid' ? 'rgba(74,222,128,0.03)' : 'var(--wiz-bg-xs)',
      transition: 'all 0.2s',
    }}>
      {/* Provider header */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderBottom: '1px solid var(--wiz-border-sub)' }}>
        <div style={{ flex: 1 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--wiz-text-hi)' }}>{PROVIDER_NAMES[provider]}</span>
            {state === 'valid' && <CheckCircle2 size={13} style={{ color: '#4ADE80' }} />}
          </div>
          <div style={{ fontSize: 11, color: 'var(--wiz-text-sub)', marginTop: 2 }}>
            Region{regionIndices.length > 1 ? 's' : ''} {regionIndices.map(i => i + 1).join(', ')}
          </div>
        </div>
      </div>

      {/* Token form */}
      <div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
        <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--wiz-text-sub)' }}>
          API credential · {PROVIDER_TOKEN_HINT[provider]}
        </span>

        {/* Input + validate row */}
        <div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
          <div style={{ flex: 1, position: 'relative' }}>
            <input
              type={show ? 'text' : 'password'}
              value={token}
              onFocus={() => setFocused(true)}
              onBlur={() => setFocused(false)}
              onChange={e => handleChange(e.target.value)}
              placeholder="Paste your credential here…"
              aria-invalid={state === 'invalid'}
              aria-describedby={failure ? `${provider}-validation-err` : undefined}
              style={{
                width: '100%', height: 38, borderRadius: 7,
                border: `1.5px solid ${failure ? 'rgba(248,113,113,0.5)' : focused ? 'rgba(56,189,248,0.45)' : 'var(--wiz-border)'}`,
                background: 'var(--wiz-bg-input)',
                color: 'var(--wiz-text-hi)', fontSize: 13, paddingLeft: 10, paddingRight: 38,
                outline: 'none', fontFamily: 'Inter, monospace',
                boxShadow: focused ? `0 0 0 3px ${failure ? 'rgba(248,113,113,0.07)' : 'rgba(56,189,248,0.07)'}` : 'none',
                transition: 'all 0.15s',
              }}
            />
            <button
              type="button"
              onClick={() => setShow(v => !v)}
              style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', color: 'var(--wiz-text-sub)', cursor: 'pointer', padding: 0, display: 'flex' }}
            >
              {show ? <EyeOff size={14} /> : <Eye size={14} />}
            </button>
          </div>

          {/* Validate button */}
          <button
            type="button"
            onClick={validate}
            disabled={state === 'validating' || state === 'valid'}
            style={{
              height: 38, padding: '0 14px', borderRadius: 7, flexShrink: 0,
              border: state === 'valid' ? '1.5px solid rgba(74,222,128,0.35)' : '1.5px solid var(--wiz-border)',
              background: state === 'valid' ? 'rgba(74,222,128,0.07)' : 'var(--wiz-bg-input)',
              color: state === 'valid' ? '#4ADE80' : 'var(--wiz-text-sub)',
              fontSize: 12, fontWeight: 600, cursor: state === 'validating' || state === 'valid' ? 'default' : 'pointer',
              display: 'flex', alignItems: 'center', gap: 5,
              fontFamily: 'Inter, sans-serif', transition: 'all 0.15s',
              whiteSpace: 'nowrap',
            }}
          >
            {state === 'validating' && <Loader2 size={12} className="animate-spin" />}
            {state === 'valid'      && <CheckCircle2 size={12} />}
            {state === 'idle'       ? 'Validate' :
             state === 'invalid'    ? 'Retry' :
             state === 'validating' ? 'Checking…' : 'Validated'}
          </button>
        </div>

        {/* Feedback */}
        {state === 'valid' && (
          <div style={{ display: 'flex', gap: 6, fontSize: 11, color: '#4ADE80', alignItems: 'center' }}>
            <CheckCircle2 size={12} /> Token validated — access confirmed
          </div>
        )}
        {failure && state === 'invalid' && (
          <ValidationErrorCard
            id={`${provider}-validation-err`}
            failure={failure}
            onRetry={validate}
            disabled={state !== 'invalid' || token.trim().length === 0}
          />
        )}

        {/* Hetzner project ID — required for resource attribution. The Hetzner Cloud API token
            is project-scoped, but we still ask for the project ID explicitly so the wizard can
            display it back during the Review step and so the provisioner can write it into
            tofu.auto.tfvars.json before terraform apply runs. */}
        {provider === 'hetzner' && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 5, marginTop: 6, paddingTop: 10, borderTop: '1px dashed var(--wiz-border-sub)' }}>
            <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--wiz-text-sub)' }}>
              Hetzner Project ID <span style={{ color: 'var(--wiz-text-hint)', marginLeft: 6 }}>required · used for resource attribution + audit log</span>
            </span>
            <input
              type="text"
              value={store.hetznerProjectId}
              onChange={e => store.setHetznerProjectId(e.target.value)}
              placeholder="e.g. proj_abc123 (find it in Hetzner Cloud Console → Project settings)"
              style={{
                height: 36, borderRadius: 7,
                border: '1.5px solid var(--wiz-border)',
                background: 'var(--wiz-bg-input)', color: 'var(--wiz-text-hi)',
                fontSize: 12, padding: '0 10px', outline: 'none',
                fontFamily: 'JetBrains Mono, monospace',
              }}
            />
          </div>
        )}

        {/* Demo bypass */}
        {state !== 'valid' && (
          <button
            type="button"
            onClick={skipDemo}
            style={{ alignSelf: 'flex-start', fontSize: 11, fontWeight: 500, color: 'var(--wiz-accent)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'underline', textUnderlineOffset: 3, fontFamily: 'Inter, sans-serif' }}
          >
            No token yet? Skip — explore in demo mode →
          </button>
        )}
      </div>
    </div>
  )
}

/**
 * ObjectStorageSection — Phase 0b credential capture for Hetzner Object
 * Storage (issue #371). Hetzner does NOT expose a Cloud API to mint
 * S3 access keys; the operator issues them once in the Hetzner Console
 * (Object Storage → Manage Credentials, secret half shown exactly once).
 * The wizard collects both halves, validates them via S3 ListBuckets
 * against the chosen region's endpoint
 * (`<region>.your-objectstorage.com`), and hands them to the catalyst-
 * api in the deployment-create payload. The OpenTofu module then
 * creates the per-Sovereign bucket via the `aminueza/minio` provider
 * and writes the credentials into the new cluster's vendor-agnostic
 * `flux-system/object-storage` Secret at cloud-init time. The seam is
 * vendor-agnostic since #425 — a future AWS / Azure / GCP / OCI
 * Sovereign uses the same Secret name + same key set.
 *
 * Region defaults to fsn1 (Falkenstein); the operator can pick nbg1
 * (Nuremberg) or hel1 (Helsinki) — Object Storage availability is
 * European-only as of 2026-04. Compute regions outside Europe (ash, hil)
 * still pick a European Object Storage region; Velero/Harbor are
 * latency-tolerant on the async backup path.
 *
 * Validation gates the wizard's Next button so a typo'd credential pair
 * surfaces here rather than 5 minutes into `tofu apply`.
 */
function ObjectStorageSection() {
  const store = useWizardStore()
  const [showSecret, setShowSecret] = useState(false)
  const [state, setState] = useState<ValidationState>(
    store.objectStorageValidated ? 'valid' : 'idle'
  )
  const [failure, setFailure] = useState<FailureDetail | null>(null)

  /**
   * Auto-default the Object Storage region from Region 1's cloud-region
   * (issue #473) — Phase-8a-preflight first live provision (deployment
   * febeeb888debf477) caught the operator clicking 'Validate' before
   * picking a region: the S3 ListBuckets call succeeded (regionless),
   * but the deployment-create POST failed server-side with
   * `object storage region is required`.
   *
   * Behaviour:
   *   • Only sets when objectStorageRegion is empty (operator override
   *     via the fsn1/nbg1/hel1 buttons is preserved — we never clobber
   *     a value the user already picked).
   *   • Mirrors the Region 1 cloud-region verbatim when it's one of
   *     fsn1/nbg1/hel1 (the European-only S3 zones).
   *   • Falls back to 'fsn1' when Region 1 is non-European (ash/hil) —
   *     Object Storage is European-only as of 2026-04 and Velero/Harbor
   *     are latency-tolerant on the async backup path (model.ts §160).
   *   • Runs once on mount; the regionCloudRegions[0] read is intentional
   *     (operators flip between Region 1 providers in StepProvider before
   *     reaching StepCredentials, so we capture the value at section
   *     mount time, not on every Region-1 change).
   */
  useEffect(() => {
    if (store.objectStorageRegion) return
    const region1 = store.regionCloudRegions[0]
    const matched: 'fsn1' | 'nbg1' | 'hel1' =
      region1 === 'nbg1' || region1 === 'hel1' ? region1 : 'fsn1'
    store.setObjectStorageRegion(matched)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  const region = store.objectStorageRegion || 'fsn1'
  const accessKey = store.objectStorageAccessKey
  const secretKey = store.objectStorageSecretKey

  function handleRegionChange(r: 'fsn1' | 'nbg1' | 'hel1') {
    store.setObjectStorageRegion(r)
    setFailure(null)
    setState('idle')
  }
  function handleAccessChange(v: string) {
    store.setObjectStorageAccessKey(v)
    setFailure(null)
    setState('idle')
  }
  function handleSecretChange(v: string) {
    store.setObjectStorageSecretKey(v)
    setFailure(null)
    setState('idle')
  }

  async function validate() {
    if (accessKey.trim().length < 16 || secretKey.trim().length < 32) {
      setFailure({
        kind: 'too-short',
        summary: 'Object Storage credentials are too short',
        hint:
          'Hetzner Object Storage access keys are at least 16 chars and secrets at least 32 chars. ' +
          'Re-issue the credential pair in Hetzner Console → Object Storage → Manage Credentials.',
      })
      setState('invalid')
      return
    }
    if (!region) {
      setFailure({
        kind: 'rejected',
        summary: 'Pick an Object Storage region',
        hint: 'Hetzner Object Storage is European-only as of 2026-04. Pick fsn1, nbg1, or hel1.',
      })
      setState('invalid')
      return
    }
    setState('validating')
    setFailure(null)

    let res: Response
    try {
      res = await fetch(`${API_BASE}/v1/credentials/object-storage/validate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ region, accessKey, secretKey }),
      })
    } catch (err) {
      setFailure({
        kind: 'network',
        summary: 'Could not reach the validation service',
        hint:
          'The wizard could not POST to /api/v1/credentials/object-storage/validate. ' +
          'You may be offline or the catalyst-api is down. Reload and retry.',
        rawMessage: String(err),
      })
      setState('invalid')
      store.setObjectStorageValidated(false)
      return
    }

    let data: { valid?: boolean; message?: string } | null = null
    try {
      data = (await res.json()) as { valid?: boolean; message?: string }
    } catch (err) {
      setFailure({
        kind: 'parse',
        summary: 'Validation service returned a malformed response',
        hint: "The backend's response could not be parsed as JSON.",
        rawMessage: String(err),
        status: res.status,
      })
      setState('invalid')
      store.setObjectStorageValidated(false)
      return
    }

    if (res.ok && data?.valid === true) {
      setState('valid')
      store.setObjectStorageValidated(true)
      return
    }

    let kind: FailureKind = 'rejected'
    if (res.status === 503) kind = 'unreachable'
    else if (res.status === 400) kind = 'too-short'
    else if (!res.ok) kind = 'http'

    const hint = kind === 'rejected'
      ? 'The credentials authenticated against Hetzner but were rejected (or the access/secret pair is wrong). ' +
        'Re-issue a fresh pair in Hetzner Console → Object Storage → Manage Credentials.'
      : kind === 'unreachable'
        ? 'The catalyst-api could not reach Hetzner Object Storage. Check status.hetzner.com and retry.'
        : FAILURE_HINTS[kind].hint

    setFailure({
      kind,
      summary: kind === 'rejected' ? 'Object Storage credentials rejected' : FAILURE_HINTS[kind].summary,
      hint,
      rawMessage: data?.message,
      status: res.status,
    })
    setState('invalid')
    store.setObjectStorageValidated(false)
  }

  const canValidate =
    accessKey.trim().length >= 16 &&
    secretKey.trim().length >= 32 &&
    !!region &&
    state !== 'validating'

  return (
    <div style={{
      borderRadius: 12, overflow: 'hidden',
      border: state === 'valid'
        ? '1.5px solid rgba(74,222,128,0.3)'
        : '1.5px solid var(--wiz-border-sub)',
      background: state === 'valid' ? 'rgba(74,222,128,0.03)' : 'var(--wiz-bg-xs)',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', borderBottom: '1px solid var(--wiz-border-sub)' }}>
        <div style={{ flex: 1 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--wiz-text-hi)' }}>Hetzner Object Storage (S3)</span>
            {state === 'valid' && <CheckCircle2 size={13} style={{ color: '#4ADE80' }} />}
          </div>
          <div style={{ fontSize: 11, color: 'var(--wiz-text-sub)', marginTop: 2 }}>
            Per-Sovereign S3 bucket — backs Harbor (image registry) + Velero (cluster backup)
          </div>
        </div>
      </div>

      <div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
        {/* Region picker */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
          <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--wiz-text-sub)' }}>
            Object Storage region
          </span>
          <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
            {(['fsn1', 'nbg1', 'hel1'] as const).map(r => (
              <button
                key={r}
                type="button"
                data-testid={`object-storage-region-${r}`}
                aria-pressed={region === r}
                onClick={() => handleRegionChange(r)}
                style={{
                  padding: '6px 12px',
                  borderRadius: 6,
                  border: `1.5px solid ${region === r ? 'rgba(56,189,248,0.5)' : 'var(--wiz-border-sub)'}`,
                  background: region === r ? 'rgba(56,189,248,0.08)' : 'var(--wiz-bg-input)',
                  color: 'var(--wiz-text-hi)',
                  fontSize: 12,
                  fontWeight: 500,
                  cursor: 'pointer',
                }}
              >
                {r === 'fsn1' ? 'fsn1 — Falkenstein' : r === 'nbg1' ? 'nbg1 — Nuremberg' : 'hel1 — Helsinki'}
              </button>
            ))}
          </div>
        </div>

        {/* Access key */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
          <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--wiz-text-sub)' }}>
            S3 access key (operator-issued — 16+ chars)
          </span>
          <input
            type="text"
            value={accessKey}
            onChange={e => handleAccessChange(e.target.value)}
            placeholder="Paste the access key half…"
            style={{
              width: '100%', height: 38, borderRadius: 7,
              border: '1.5px solid var(--wiz-border)',
              background: 'var(--wiz-bg-input)',
              color: 'var(--wiz-text-hi)', fontSize: 13, padding: '0 10px',
              outline: 'none', fontFamily: 'Inter, monospace',
            }}
          />
        </div>

        {/* Secret key */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
          <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--wiz-text-sub)' }}>
            S3 secret key (32+ chars · shown by Hetzner exactly once at issue time)
          </span>
          <div style={{ position: 'relative' }}>
            <input
              type={showSecret ? 'text' : 'password'}
              value={secretKey}
              onChange={e => handleSecretChange(e.target.value)}
              placeholder="Paste the secret key half…"
              style={{
                width: '100%', height: 38, borderRadius: 7,
                border: '1.5px solid var(--wiz-border)',
                background: 'var(--wiz-bg-input)',
                color: 'var(--wiz-text-hi)', fontSize: 13, paddingLeft: 10, paddingRight: 38,
                outline: 'none', fontFamily: 'Inter, monospace',
              }}
            />
            <button
              type="button"
              onClick={() => setShowSecret(!showSecret)}
              style={{
                position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
                background: 'none', border: 'none', color: 'var(--wiz-text-sub)', cursor: 'pointer', padding: 4,
              }}
            >
              {showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
            </button>
          </div>
        </div>

        {/* Validate button */}
        <button
          type="button"
          onClick={validate}
          disabled={!canValidate}
          style={{
            alignSelf: 'flex-start',
            padding: '8px 16px',
            borderRadius: 7,
            border: 'none',
            background: state === 'valid'
              ? 'rgba(74,222,128,0.15)'
              : canValidate ? 'var(--wiz-accent)' : 'var(--wiz-bg-input)',
            color: state === 'valid' ? '#4ADE80' : canValidate ? 'white' : 'var(--wiz-text-sub)',
            fontSize: 12,
            fontWeight: 600,
            cursor: canValidate ? 'pointer' : 'not-allowed',
            display: 'flex',
            alignItems: 'center',
            gap: 6,
          }}
        >
          {state === 'validating' ? (
            <>
              <Loader2 size={14} className="animate-spin" /> Validating…
            </>
          ) : state === 'valid' ? (
            <>
              <CheckCircle2 size={14} /> S3 access confirmed
            </>
          ) : (
            'Validate'
          )}
        </button>

        {failure && (
          <div style={{
            borderRadius: 8,
            border: '1px solid rgba(248,113,113,0.4)',
            background: 'rgba(248,113,113,0.04)',
            padding: '10px 12px',
            display: 'flex',
            gap: 10,
            alignItems: 'flex-start',
          }}>
            <AlertCircle size={14} style={{ color: '#F87171', flexShrink: 0, marginTop: 2 }} />
            <div style={{ flex: 1, fontSize: 12 }}>
              <div style={{ fontWeight: 600, color: '#FCA5A5', marginBottom: 4 }}>{failure.summary}</div>
              <div style={{ color: 'var(--wiz-text-sub)' }}>{failure.hint}</div>
              {failure.rawMessage && (
                <div style={{ marginTop: 6, fontSize: 11, color: 'var(--wiz-text-sub)', fontFamily: 'JetBrains Mono, monospace' }}>
                  Backend: {failure.rawMessage}
                </div>
              )}
            </div>
          </div>
        )}

        {state !== 'valid' && (
          <p style={{ fontSize: 11, color: 'var(--wiz-text-sub)', margin: 0, lineHeight: 1.5 }}>
            Issue keys at <a href="https://console.hetzner.cloud" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--wiz-accent)' }}>Hetzner Console</a> → Object Storage → Manage Credentials.
            Hetzner shows the secret half exactly once; if you lose it, issue a fresh pair.
          </p>
        )}
      </div>
    </div>
  )
}

export function StepCredentials() {
  const store = useWizardStore()
  const { next, back } = useStepNav()
  const bp = useBreakpoint()

  const uniqueProviders = [...new Set(Object.values(store.regionProviders))] as CloudProvider[]
  const providers: CloudProvider[] = uniqueProviders.length > 0
    ? uniqueProviders
    : store.provider ? [store.provider] : ['hetzner']

  const allValidated = providers.every(p => store.providerValidated[p])
  // Security floor (#160): the OpenTofu module rejects empty/invalid keys;
  // gate the wizard's Next button on the same regex so the user can't reach
  // StepReview with a payload the backend will refuse.
  const sshKeyOk = isValidSSHPublicKey(store.sshPublicKey)
  // Hetzner Object Storage gate (issue #371) — only enforced when the
  // operator selected hetzner as a provider (Phase 0b is hetzner-only as
  // of 2026-04). Hetzner exposes no API to mint S3 credentials, so the
  // wizard collects them and the catalyst-api validates them via S3
  // ListBuckets BEFORE the deployment payload reaches Validate().
  const objectStorageRequired = providers.includes('hetzner')
  const objectStorageOk = !objectStorageRequired || store.objectStorageValidated
  const canProceed = allValidated && sshKeyOk && objectStorageOk

  const regionIndicesFor = (p: CloudProvider) =>
    Object.entries(store.regionProviders)
      .filter(([, v]) => v === p)
      .map(([k]) => Number(k))

  /* 2-col when 2+ providers AND not mobile */
  const cols = providers.length >= 2 && bp !== 'mobile' ? '1fr 1fr' : '1fr'

  return (
    <StepShell
      title="Cloud credentials"
      description={providers.length > 1
        ? `You selected ${providers.length} different cloud providers. Provide one API credential per provider, plus an SSH public key for break-glass access.`
        : 'Provide a read/write API credential plus an SSH public key. Credentials are used only during provisioning and never persisted on our servers.'}
      onNext={() => { if (canProceed) next() }}
      onBack={back}
      nextDisabled={!canProceed}
    >
      {/* Credential sections */}
      <div style={{ display: 'grid', gridTemplateColumns: cols, gap: 14 }}>
        {providers.map(p => (
          <TokenSection
            key={p}
            provider={p}
            regionIndices={regionIndicesFor(p).length > 0 ? regionIndicesFor(p) : [0]}
          />
        ))}
      </div>

      {/* SSH keypair — required for hcloud_ssh_key + sovereign-admin
          break-glass access. Closes #160. */}
      <SSHKeySection />

      {/* Hetzner Object Storage — Phase 0b (issue #371). Operator-issued
          S3 credentials feed Harbor (#383) + Velero (#384). Only rendered
          when hetzner is among the chosen providers; the section is
          a no-op for AWS/Azure/OCI/Huawei (each cloud has its own
          backing-store path that lands in their respective bp-* charts). */}
      {objectStorageRequired && <ObjectStorageSection />}

      {/* How-to for Hetzner */}
      {providers.includes('hetzner') && !allValidated && (
        <div style={{ borderRadius: 10, border: '1px solid var(--wiz-border-sub)', background: 'var(--wiz-bg-xs)', padding: '12px 14px' }}>
          <p style={{ fontSize: 11, fontWeight: 600, color: 'var(--wiz-text-sub)', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
            How to create a Hetzner API token
          </p>
          <ol style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 4 }}>
            {[
              'Open Hetzner Cloud Console',
              'Select your project',
              'Go to Security \u2192 API Tokens',
              'Click Generate API Token',
              'Choose Read & Write permissions',
              'Copy the token \u2014 shown only once',
            ].map((s, i) => (
              <li key={i} style={{ display: 'flex', gap: 8, fontSize: 11, color: 'var(--wiz-text-sub)', alignItems: 'flex-start' }}>
                <span style={{ width: 15, height: 15, borderRadius: '50%', background: 'var(--wiz-bg-input)', border: '1px solid var(--wiz-border-sub)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700, flexShrink: 0 }}>{i + 1}</span>
                {s}
              </li>
            ))}
          </ol>
          <a href="https://console.hetzner.cloud" target="_blank" rel="noopener noreferrer" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginTop: 8, fontSize: 11, color: 'var(--wiz-accent)', textDecoration: 'none' }}>
            Open Hetzner Cloud Console <ExternalLink size={10} />
          </a>
        </div>
      )}
    </StepShell>
  )
}

/**
 * ValidationErrorCard — inline error banner shown beneath the credential
 * input when validate() determines the token is invalid OR validation
 * itself failed.
 *
 * Per docs/INVIOLABLE-PRINCIPLES.md #2 ("never compromise from quality")
 * we surface the exact failure mode + remediation hint + the raw backend
 * message verbatim so the operator can self-recover without filing a
 * support ticket.
 *
 * Closes #123 ([I] ux: error handling — what happens if Hetzner API
 * rejects token).
 */
function ValidationErrorCard({
  id,
  failure,
  onRetry,
  disabled,
}: {
  id: string
  failure: FailureDetail
  onRetry: () => void | Promise<void>
  disabled: boolean
}) {
  const [retrying, setRetrying] = useState(false)
  const [copied, setCopied] = useState(false)

  async function retry() {
    setRetrying(true)
    try {
      await onRetry()
    } finally {
      setRetrying(false)
    }
  }

  async function copyDiagnostic() {
    const blob = JSON.stringify(
      {
        kind: failure.kind,
        summary: failure.summary,
        status: failure.status,
        rawMessage: failure.rawMessage,
      },
      null,
      2,
    )
    await navigator.clipboard.writeText(blob)
    setCopied(true)
    setTimeout(() => setCopied(false), 2000)
  }

  return (
    <div
      id={id}
      role="alert"
      style={{
        borderRadius: 8,
        border: '1px solid rgba(248,113,113,0.35)',
        background: 'rgba(248,113,113,0.05)',
        padding: '10px 12px',
        display: 'flex',
        flexDirection: 'column',
        gap: 8,
      }}
    >
      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
        <AlertCircle size={14} style={{ color: '#F87171', flexShrink: 0, marginTop: 1 }} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
            <span style={{ fontSize: 12, fontWeight: 700, color: '#F87171' }}>{failure.summary}</span>
            {failure.status !== undefined && (
              <span
                style={{
                  fontSize: 9,
                  fontWeight: 700,
                  fontFamily: 'JetBrains Mono, monospace',
                  color: '#F87171',
                  background: 'rgba(248,113,113,0.12)',
                  padding: '1px 6px',
                  borderRadius: 3,
                }}
              >
                HTTP {failure.status}
              </span>
            )}
          </div>
          <p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--wiz-text-md)', lineHeight: 1.5 }}>
            {failure.hint}
          </p>
          {failure.rawMessage && (
            <pre
              style={{
                margin: '6px 0 0',
                padding: '6px 8px',
                fontSize: 10.5,
                fontFamily: 'JetBrains Mono, monospace',
                color: 'var(--wiz-text-md)',
                background: 'rgba(0,0,0,0.25)',
                borderRadius: 5,
                whiteSpace: 'pre-wrap',
                wordBreak: 'break-word',
              }}
            >
              {failure.rawMessage}
            </pre>
          )}
        </div>
      </div>

      <div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
        <button
          type="button"
          onClick={retry}
          disabled={disabled || retrying}
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            gap: 5,
            height: 28,
            padding: '0 10px',
            borderRadius: 6,
            border: '1px solid rgba(56,189,248,0.4)',
            background: 'rgba(56,189,248,0.1)',
            color: 'var(--wiz-accent)',
            fontSize: 11,
            fontWeight: 600,
            cursor: disabled || retrying ? 'default' : 'pointer',
            opacity: disabled ? 0.5 : 1,
            fontFamily: 'Inter, sans-serif',
          }}
        >
          {retrying ? <Loader2 size={11} className="animate-spin" /> : <RotateCw size={11} />}
          Retry validation
        </button>

        <button
          type="button"
          onClick={copyDiagnostic}
          style={{
            display: 'inline-flex',
            alignItems: 'center',
            gap: 5,
            height: 28,
            padding: '0 10px',
            borderRadius: 6,
            border: '1px solid var(--wiz-border)',
            background: 'transparent',
            color: 'var(--wiz-text-md)',
            fontSize: 11,
            fontWeight: 500,
            cursor: 'pointer',
            fontFamily: 'Inter, sans-serif',
          }}
        >
          <Copy size={11} />
          {copied ? 'Copied' : 'Copy diagnostic'}
        </button>

        <span style={{ fontSize: 10, color: 'var(--wiz-text-hint)', marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
          <XCircle size={10} /> Token will not be persisted on our servers either way
        </span>
      </div>
    </div>
  )
}

/* ── SSH keypair section (issue #160) ─────────────────────────────────
 *
 * Two-mode UX:
 *
 *   Mode A — "Generate keypair"
 *     Single button → POST /api/v1/sshkey/generate → catalyst-api emits an
 *     Ed25519 keypair → wizard stores the public key + downloads the
 *     private key as `<sovereign-fqdn>.pem` (or `catalyst.pem` if the FQDN
 *     isn't filled in yet) → renders the public key inline (read-only) and
 *     a one-time "save your private key now" warning.
 *
 *   Mode B — "Paste existing public key"
 *     Textarea, RFC validation regex (mirrors infra/hetzner/variables.tf).
 *     Sets store.sshPublicKey directly; clears any private blob held over
 *     from a Mode-A generation in the same session.
 *
 * Per docs/INVIOLABLE-PRINCIPLES.md:
 *   • #4 (never hardcode): the FQDN sent to the backend is computed from
 *     wizard state, never a literal "omantel.omani.works".
 *   • #10 (credential hygiene): the private key is held only long enough
 *     to trigger the download. partialize() in the store strips it from
 *     the persisted payload regardless.
 */
function SSHKeySection() {
  const store = useWizardStore()
  const initialMode: 'generate' | 'paste' = store.sshPublicKey && !store.sshKeyGeneratedThisSession ? 'paste' : 'generate'
  const [mode, setMode] = useState<'generate' | 'paste'>(initialMode)
  const [generating, setGenerating] = useState(false)
  const [pasted, setPasted] = useState(store.sshPublicKey)
  const [error, setError] = useState<string | null>(null)

  const sovereignFQDN = resolveSovereignDomain(store)
  const downloadFilename = `${sovereignFQDN || 'catalyst'}.pem`

  async function generate() {
    setError(null)
    setGenerating(true)
    let res: Response
    try {
      res = await fetch(`${API_BASE}/v1/sshkey/generate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ fqdn: sovereignFQDN }),
      })
    } catch (err) {
      setError(`Could not reach the keypair generator (${String(err)}). Reload and retry.`)
      setGenerating(false)
      return
    }
    if (!res.ok) {
      setError(`Generator returned HTTP ${res.status}. Reload and retry.`)
      setGenerating(false)
      return
    }
    let data: { publicKey?: string; privateKey?: string; fingerprint?: string } | null = null
    try {
      data = (await res.json()) as { publicKey?: string; privateKey?: string; fingerprint?: string }
    } catch (err) {
      setError(`Generator response was malformed (${String(err)}).`)
      setGenerating(false)
      return
    }
    if (!data?.publicKey || !data?.privateKey) {
      setError('Generator response was missing publicKey or privateKey.')
      setGenerating(false)
      return
    }
    store.setSshGenerated(data.publicKey, data.privateKey, data.fingerprint ?? '')
    triggerPrivateKeyDownload(data.privateKey, downloadFilename)
    setGenerating(false)
  }

  function handlePaste(value: string) {
    setPasted(value)
    setError(null)
    if (value.trim() === '') {
      // Clear store immediately on empty so the wizard can't keep an old key in scope.
      store.setSshPublicKey('')
      return
    }
    if (!isValidSSHPublicKey(value)) {
      // Don't write into the store yet — leave the previous valid value (if any)
      // intact, but surface the error so the user can correct it.
      setError(
        'Public key did not parse. Paste a single line beginning with ' +
          '"ssh-ed25519", "ssh-rsa", or "ecdsa-sha2-nistp256/384/521" followed by a base64 blob.',
      )
      return
    }
    store.setSshPublicKey(value.trim())
  }

  function reDownload() {
    if (!store.sshPrivateKeyOnce) return
    triggerPrivateKeyDownload(store.sshPrivateKeyOnce, downloadFilename)
  }

  const sshKeyOk = isValidSSHPublicKey(store.sshPublicKey)

  return (
    <div
      data-testid="ssh-key-section"
      style={{
        borderRadius: 12,
        overflow: 'hidden',
        border: sshKeyOk ? '1.5px solid rgba(74,222,128,0.3)' : '1.5px solid var(--wiz-border-sub)',
        background: sshKeyOk ? 'rgba(74,222,128,0.03)' : 'var(--wiz-bg-xs)',
        transition: 'all 0.2s',
      }}
    >
      {/* Header */}
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: 10,
          padding: '10px 14px',
          borderBottom: '1px solid var(--wiz-border-sub)',
        }}
      >
        <KeyRound size={14} style={{ color: 'var(--wiz-text-md)' }} />
        <div style={{ flex: 1 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--wiz-text-hi)' }}>
              SSH public key
            </span>
            {sshKeyOk && <CheckCircle2 size={13} style={{ color: '#4ADE80' }} />}
            <span
              style={{
                fontSize: 9,
                fontWeight: 700,
                fontFamily: 'JetBrains Mono, monospace',
                color: '#FBBF24',
                background: 'rgba(251,191,36,0.12)',
                padding: '1px 6px',
                borderRadius: 3,
              }}
            >
              REQUIRED
            </span>
          </div>
          <div style={{ fontSize: 11, color: 'var(--wiz-text-sub)', marginTop: 2 }}>
            Break-glass access if k3s + Flux reconcile fails post-bootstrap. Hetzner attaches this to every server.
          </div>
        </div>
      </div>

      {/* Mode tabs */}
      <div
        role="tablist"
        aria-label="SSH key input mode"
        style={{ display: 'flex', borderBottom: '1px solid var(--wiz-border-sub)' }}
      >
        <ModeTab
          testid="ssh-mode-generate"
          active={mode === 'generate'}
          onClick={() => setMode('generate')}
          icon={<Sparkles size={12} />}
          label="Generate keypair"
          hint="Recommended"
        />
        <ModeTab
          testid="ssh-mode-paste"
          active={mode === 'paste'}
          onClick={() => setMode('paste')}
          icon={<ClipboardPaste size={12} />}
          label="Paste existing public key"
          hint="Advanced"
        />
      </div>

      {/* Mode body */}
      <div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
        {mode === 'generate' && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            {!store.sshKeyGeneratedThisSession && (
              <button
                type="button"
                data-testid="ssh-generate-button"
                onClick={generate}
                disabled={generating}
                style={{
                  alignSelf: 'flex-start',
                  height: 36,
                  padding: '0 14px',
                  borderRadius: 7,
                  border: '1.5px solid rgba(56,189,248,0.4)',
                  background: 'rgba(56,189,248,0.1)',
                  color: 'var(--wiz-accent)',
                  fontSize: 12,
                  fontWeight: 600,
                  cursor: generating ? 'default' : 'pointer',
                  display: 'inline-flex',
                  alignItems: 'center',
                  gap: 6,
                  fontFamily: 'Inter, sans-serif',
                }}
              >
                {generating ? <Loader2 size={12} className="animate-spin" /> : <Sparkles size={12} />}
                {generating ? 'Generating Ed25519 keypair…' : 'Generate Ed25519 keypair'}
              </button>
            )}

            {store.sshKeyGeneratedThisSession && (
              <>
                {/* One-time warning banner — closes the part of the spec
                    that says "Private key shown once. Save it now or you
                    lose access." */}
                <div
                  data-testid="ssh-private-key-warning"
                  role="alert"
                  style={{
                    borderRadius: 8,
                    border: '1px solid rgba(251,191,36,0.45)',
                    background: 'rgba(251,191,36,0.07)',
                    padding: '10px 12px',
                    display: 'flex',
                    alignItems: 'flex-start',
                    gap: 8,
                  }}
                >
                  <ShieldAlert size={14} style={{ color: '#FBBF24', flexShrink: 0, marginTop: 1 }} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 12, fontWeight: 700, color: '#FBBF24' }}>
                      Private key shown once. Save it now or you lose access.
                    </div>
                    <p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--wiz-text-md)', lineHeight: 1.55 }}>
                      Your browser downloaded <code>{downloadFilename}</code>. Move it to <code>~/.ssh/</code> and run{' '}
                      <code>chmod 600 ~/.ssh/{downloadFilename}</code>. The catalyst-api has discarded its copy.
                    </p>
                    <div style={{ display: 'flex', gap: 6, marginTop: 8, flexWrap: 'wrap' }}>
                      <button
                        type="button"
                        data-testid="ssh-redownload"
                        onClick={reDownload}
                        disabled={!store.sshPrivateKeyOnce}
                        style={{
                          height: 26,
                          padding: '0 9px',
                          borderRadius: 6,
                          border: '1px solid rgba(251,191,36,0.45)',
                          background: 'rgba(251,191,36,0.1)',
                          color: '#FBBF24',
                          fontSize: 11,
                          fontWeight: 600,
                          cursor: store.sshPrivateKeyOnce ? 'pointer' : 'default',
                          display: 'inline-flex',
                          alignItems: 'center',
                          gap: 4,
                          fontFamily: 'Inter, sans-serif',
                          opacity: store.sshPrivateKeyOnce ? 1 : 0.5,
                        }}
                      >
                        <Download size={11} />
                        {store.sshPrivateKeyOnce ? 'Download again' : 'Already downloaded'}
                      </button>
                      <button
                        type="button"
                        onClick={() => {
                          // Re-generate path — discards the prior public key
                          // and emits a fresh keypair. Useful if the user
                          // accidentally lost the .pem and wants a new one
                          // without leaving the wizard.
                          store.clearSshPrivateKey()
                          generate()
                        }}
                        style={{
                          height: 26,
                          padding: '0 9px',
                          borderRadius: 6,
                          border: '1px solid var(--wiz-border)',
                          background: 'transparent',
                          color: 'var(--wiz-text-md)',
                          fontSize: 11,
                          fontWeight: 500,
                          cursor: 'pointer',
                          display: 'inline-flex',
                          alignItems: 'center',
                          gap: 4,
                          fontFamily: 'Inter, sans-serif',
                        }}
                      >
                        <RotateCw size={11} /> Re-generate
                      </button>
                    </div>
                  </div>
                </div>

                {/* Read-only public-key preview */}
                <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
                  <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--wiz-text-sub)' }}>
                    Public key — embedded in tofu apply
                  </span>
                  <textarea
                    data-testid="ssh-public-key-preview"
                    value={store.sshPublicKey}
                    readOnly
                    rows={3}
                    style={{
                      width: '100%',
                      borderRadius: 7,
                      border: '1.5px solid var(--wiz-border)',
                      background: 'var(--wiz-bg-input)',
                      color: 'var(--wiz-text-hi)',
                      fontSize: 11.5,
                      padding: '8px 10px',
                      outline: 'none',
                      fontFamily: 'JetBrains Mono, monospace',
                      resize: 'vertical',
                      wordBreak: 'break-all',
                    }}
                  />
                  {store.sshFingerprint && (
                    <span
                      data-testid="ssh-fingerprint"
                      style={{ fontSize: 10.5, color: 'var(--wiz-text-hint)', fontFamily: 'JetBrains Mono, monospace' }}
                    >
                      Fingerprint: {store.sshFingerprint}
                    </span>
                  )}
                </div>
              </>
            )}

            {!store.sshKeyGeneratedThisSession && (
              <p style={{ margin: 0, fontSize: 11, color: 'var(--wiz-text-sub)', lineHeight: 1.55 }}>
                The catalyst-api will emit a fresh Ed25519 keypair, ship the private half to your browser as a one-time
                download, and forget it. The public half is stored only as long as the wizard stays open.
              </p>
            )}
          </div>
        )}

        {mode === 'paste' && (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
            <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--wiz-text-sub)' }}>
              Public key (single line, OpenSSH authorized_keys format)
            </span>
            <textarea
              data-testid="ssh-paste-input"
              value={pasted}
              onChange={(e) => handlePaste(e.target.value)}
              placeholder="ssh-ed25519 AAAA… user@host"
              rows={3}
              aria-invalid={error !== null}
              style={{
                width: '100%',
                borderRadius: 7,
                border: error
                  ? '1.5px solid rgba(248,113,113,0.5)'
                  : sshKeyOk
                    ? '1.5px solid rgba(74,222,128,0.45)'
                    : '1.5px solid var(--wiz-border)',
                background: 'var(--wiz-bg-input)',
                color: 'var(--wiz-text-hi)',
                fontSize: 11.5,
                padding: '8px 10px',
                outline: 'none',
                fontFamily: 'JetBrains Mono, monospace',
                resize: 'vertical',
              }}
            />
            {error && (
              <div
                data-testid="ssh-paste-error"
                role="alert"
                style={{
                  display: 'flex',
                  gap: 6,
                  fontSize: 11,
                  color: '#F87171',
                  alignItems: 'flex-start',
                  lineHeight: 1.45,
                }}
              >
                <AlertCircle size={12} style={{ flexShrink: 0, marginTop: 1 }} />
                <span>{error}</span>
              </div>
            )}
            {!error && sshKeyOk && (
              <div style={{ display: 'flex', gap: 6, fontSize: 11, color: '#4ADE80', alignItems: 'center' }}>
                <CheckCircle2 size={12} /> Looks like a valid OpenSSH public key.
              </div>
            )}
            <span style={{ fontSize: 10.5, color: 'var(--wiz-text-hint)', lineHeight: 1.5 }}>
              Accepted algorithms: <code>ssh-ed25519</code>, <code>ssh-rsa</code>,{' '}
              <code>ecdsa-sha2-nistp256/384/521</code>. The provisioner runs the same regex server-side — empty or
              malformed keys are rejected (security floor — never an empty SSH key).
            </span>
          </div>
        )}
      </div>
    </div>
  )
}

function ModeTab({
  testid,
  active,
  onClick,
  icon,
  label,
  hint,
}: {
  testid: string
  active: boolean
  onClick: () => void
  icon: React.ReactNode
  label: string
  hint: string
}) {
  return (
    <button
      type="button"
      role="tab"
      aria-selected={active}
      data-testid={testid}
      onClick={onClick}
      style={{
        flex: 1,
        height: 38,
        background: active ? 'rgba(56,189,248,0.07)' : 'transparent',
        border: 'none',
        borderBottom: active ? '2px solid var(--wiz-accent)' : '2px solid transparent',
        color: active ? 'var(--wiz-text-hi)' : 'var(--wiz-text-sub)',
        fontSize: 12,
        fontWeight: active ? 600 : 500,
        cursor: 'pointer',
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        gap: 6,
        fontFamily: 'Inter, sans-serif',
        transition: 'all 0.15s',
      }}
    >
      {icon}
      {label}
      <span style={{ fontSize: 10, color: 'var(--wiz-text-hint)', fontWeight: 500 }}>· {hint}</span>
    </button>
  )
}

/**
 * triggerPrivateKeyDownload — creates a one-shot Blob URL for the private
 * key blob and clicks a hidden anchor to make the browser save it. The URL
 * is revoked immediately afterwards so the blob is GC'd as soon as the
 * download dialog has copied the bytes.
 *
 * Per credential hygiene (#10), this is the ONLY moment the private key
 * touches the DOM; nothing renders it inline.
 */
function triggerPrivateKeyDownload(privateKey: string, filename: string) {
  // jsdom in vitest doesn't implement Blob URLs the same way browsers do;
  // guard so the test environment doesn't crash when the helper runs.
  if (typeof window === 'undefined' || typeof document === 'undefined') return
  try {
    const blob = new Blob([privateKey], { type: 'application/x-pem-file' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = filename
    a.style.display = 'none'
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    URL.revokeObjectURL(url)
  } catch {
    // Swallow — the user can still click "Download again" from the warning
    // banner, which calls back into this helper. We log nothing so the
    // private key never appears in console.error stack traces.
  }
}
