'use client';

/**
 * Onboarding wizard — first-run setup banner + right-side drawer.
 *
 * - Banner: thin bar at top of Dashboard when `settings.onboardingCompleted` is
 *   false. Clicking it opens the drawer. Drawer is a slide-in panel ~520px
 *   wide; the rest of the UI stays visible behind it (per user request).
 * - 6 sections: Identity / API Profile / Connectors / Pipelines / Projects /
 *   Memory. Apply hits POST /api/onboarding (action:apply) once with the
 *   full payload — backend phases each step atomically and reports back.
 *
 * Re-runnable from Settings → "Re-run Onboarding" (POST action:reset).
 */

import { useEffect, useRef, useState } from 'react';

// ─── Types echoing the API surface ───────────────────────────

interface PromptDef {
  label: string;
  hint?: string;
  url?: string;
  url_label?: string;
  secret?: boolean;
  required?: boolean;
  /** Override the auto-detected group (default = first target's connector). */
  group?: string;
  /** Pre-filled value shown in the input on first render. */
  default?: string;
}

interface OnboardingState {
  ok: boolean;
  onboardingCompleted: boolean;
  current: {
    displayName: string;
    displayEmail: string;
    apiProfileIds: string[];
    apiProfile: {
      id: string; name: string;
      provider: 'anthropic' | 'openai-compatible';
      model: string; baseUrl: string;
      apiKeySet: boolean;
    } | null;
    chatAgent: string;
    defaultAgent: string;
    agents: Array<{ id: string; name: string; tool: string; path: string; enabled: boolean }>;
    projectRoots: string[];
  };
  detected_cli: Array<{ name: string; path: string; version: string }>;
  /** All non-meta connector ids in the template, in template order. */
  template_connectors: Array<{
    id: string;
    default_enabled: boolean;
    has_prompts: boolean;
    already_installed: boolean;
    /** Template-baked field values for this connector (excluding ${...}
     *  prompts, which appear in section 3). Secrets are masked to
     *  '••••••••'. Instances arrays are flattened to '<name>.<field>'. */
    defaults?: Array<{ field: string; value: string; is_secret: boolean }>;
  }>;
  template_prompts: Record<string, PromptDef>;
  /** Per ${key} prompt: true = at least one target field already has a real value;
   *  leave blank to keep it. Backend's applyConnectors preserves existing on empty. */
  prompt_values_set: Record<string, boolean>;
  /** Per ${key}: which connector.field references it (for UI hint). */
  prompt_targets: Record<string, Array<{ connector: string; field: string }>>;
  /** Pre-baked CLI agents the template will install (from `_agents`). */
  template_agents_preview?: Array<{ id: string; tool?: string; has_secret: boolean }>;
  /** Pre-baked API profiles the template will install (from `_apiProfiles`). */
  template_api_profiles_preview?: Array<{ id: string; provider?: string; model?: string; has_secret: boolean }>;
  /** Keys the template auto-derives from other prompts (e.g. user_email_local). */
  template_derive_keys?: string[];
  /** Lowercased enterprise name when the template is enterprise-sourced. */
  template_enterprise_name?: string;
  /** Wizard layout knobs declared by template._wizard — controls how much of
   *  the wizard's collapsing/hiding logic kicks in. Enterprise templates with
   *  nearly-everything baked set minimal:true so the user only sees Identity
   *  + required prompts + preview. */
  template_wizard?: {
    minimal: boolean;
    hide_connectors: boolean;
    hide_pipelines: boolean;
    required_only: boolean;
  };
  /** When the template ships a `_temperAdmin: {url, token}` block (or the user
   *  set temperAdminToken manually), Forge will auto-provision a Temper memory
   *  account on Apply. `provisioned` reflects whether settings.temperKey is
   *  already populated (= no re-provision will happen). */
  memory_auto_provision?: {
    url: string;
    provisioned: boolean;
  };
  suggested_pipelines: string[];
  /** E2: list of source ids the wizard can switch between. Powers the
   *  tenant selector at the top. `public` always present; user override
   *  + each enterprise tenant appear when configured. */
  available_sources?: Array<{ id: string; display_name: string; has_template: boolean }>;
  /** Source id that actually fed the rendered template — null when none
   *  cached (bundled fallback in use). */
  resolved_source?: string | null;
  /** The source_id the GET request asked for (null when none requested). */
  requested_source_id?: string | null;
  /** E3: dept picker — list of departments within the currently-scoped
   *  source. Empty = single-template source, no picker shown. */
  available_departments?: Array<{ id: string; display_name: string }>;
  /** Dept id actually rendered (defaults to first dept when none requested). */
  resolved_dept?: string | null;
  requested_dept_id?: string | null;
  /** Template's self-declared `_department_name`. Surfaced for the
   *  breadcrumb so the user sees what {dept.name} resolves to. */
  template_department_name?: string;
  /** Per-template identity validation. Currently supports
   *  `email_pattern` (JS-compatible regex) — e.g. fortinet enforces
   *  ^[^@]+@fortinet(-[a-z]+)?\.com$. Optional. */
  template_identity?: {
    email_pattern?: string;
    email_pattern_hint?: string;
  };
}

interface ApplyResultPhase {
  phase: string;
  ok: boolean;
  error?: string;
  detail?: any;
}

// ─── Banner ──────────────────────────────────────────────────

export function OnboardingBanner({ onOpen }: { onOpen: () => void }) {
  return (
    <div className="bg-[var(--accent)]/10 border-b border-[var(--accent)]/40 px-3 py-1.5 flex items-center justify-between text-[11px] text-[var(--text-primary)]">
      <span>
        <span className="font-medium">Welcome to Forge.</span>{' '}
        Finish setup — identity, API key, connectors, pipelines (1 click).
      </span>
      <div className="flex items-center gap-2">
        <button
          onClick={onOpen}
          className="text-[11px] px-2.5 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
        >
          Open setup →
        </button>
        <button
          onClick={async () => {
            await fetch('/api/onboarding', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ action: 'apply', payload: {} }),
            });
            window.location.reload();
          }}
          className="text-[10px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
          title="Mark setup as completed without changes"
        >
          Skip
        </button>
      </div>
    </div>
  );
}

// ─── Drawer ──────────────────────────────────────────────────

const inputCls = 'w-full text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]';

function buildOnboardingUrl(sourceId: string | null, deptId: string | null): string {
  const q = new URLSearchParams();
  if (sourceId) q.set('source_id', sourceId);
  if (deptId) q.set('dept', deptId);
  return q.size ? `/api/onboarding?${q.toString()}` : '/api/onboarding';
}

const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string }> = {
  anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' },
  deepseek: { baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
  openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
  qwen: { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-max' },
  // LiteLLM proxy — baseUrl points to your LiteLLM instance (default
  // local). Lets one token-cap profile front many backend models.
  litellm: { baseUrl: 'http://127.0.0.1:4000/v1', model: 'gpt-4o-mini' },
};

export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onClose: () => void; onComplete: () => void; initialSourceId?: string | null }) {
  const [state, setState] = useState<OnboardingState | null>(null);
  // E2: which source's template is currently scoped to. null = use the
  // default priority chain (server's resolveTemplate decides). Changing
  // this triggers a refetch + re-render of the entire wizard so prompts
  // and connector lists swap to the chosen tenant's template.
  // E4: when EnterpriseBadge hands off after a fresh key add, the
  // source id arrives via prop so the wizard renders scoped from the
  // first mount.
  const [currentSource, setCurrentSource] = useState<string | null>(initialSourceId ?? null);
  // E3: which dept within `currentSource`. null = use the source's first
  // dept. Changing this re-fetches the wizard state.
  const [currentDept, setCurrentDept] = useState<string | null>(null);

  // Section 1: Identity
  const [displayName, setDisplayName] = useState('');
  const [displayEmail, setDisplayEmail] = useState('');

  // Section 2: API Profile
  const [apiProfileId, setApiProfileId] = useState('deepseek');
  const [apiProvider, setApiProvider] = useState<'anthropic' | 'openai-compatible'>('openai-compatible');
  const [apiBaseUrl, setApiBaseUrl] = useState(PROVIDER_DEFAULTS.deepseek.baseUrl);
  const [apiModel, setApiModel] = useState(PROVIDER_DEFAULTS.deepseek.model);
  const [apiKey, setApiKey] = useState('');
  const [apiKeyExisting, setApiKeyExisting] = useState(false);   // true → "leave blank to keep current"
  const [apiSetAsDefault, setApiSetAsDefault] = useState(true);

  // Section 2.5: CLI Agent
  const [cliAgentId, setCliAgentId] = useState('');             // '' = don't configure
  const [cliAgentTool, setCliAgentTool] = useState<'claude' | 'codex' | 'aider' | 'opencode'>('claude');
  const [cliAgentPath, setCliAgentPath] = useState('');
  const [cliSetAsDefault, setCliSetAsDefault] = useState(true);

  // Section 3: Connector template values (one per ${key})
  const [connectorValues, setConnectorValues] = useState<Record<string, string>>({});
  // Section 3.5: which template connectors to install. Default = all
  // (populated from template_connectors on state load). User-typed values
  // for un-selected connectors are kept in connectorValues so re-checking
  // restores them — only the payload skips unselected ones.
  const [selectedConnectors, setSelectedConnectors] = useState<Set<string>>(new Set());

  // Section 4: Pipelines
  const [selectedPipelines, setSelectedPipelines] = useState<Set<string>>(new Set());

  // Section 5: Projects
  const [projectInput, setProjectInput] = useState('');
  // Folder picker — uses Forge's existing /api/fs/browse endpoint.
  const [pickerOpen, setPickerOpen] = useState(false);
  const [pickerPath, setPickerPath] = useState('');
  const [pickerEntries, setPickerEntries] = useState<Array<{ name: string; path: string }>>([]);
  const [pickerParent, setPickerParent] = useState<string | null>(null);

  async function openPicker(path?: string) {
    setPickerOpen(true);
    try {
      const url = path ? `/api/fs/browse?path=${encodeURIComponent(path)}` : '/api/fs/browse';
      const r = await fetch(url);
      const j = await r.json();
      if (j.ok) {
        setPickerPath(j.path);
        setPickerEntries(j.entries || []);
        setPickerParent(j.parent || null);
      }
    } catch { /* ignore */ }
  }

  function addPickedPath(path: string) {
    const lines = projectInput.split('\n').map(s => s.trim()).filter(Boolean);
    if (!lines.includes(path)) lines.push(path);
    setProjectInput(lines.join('\n'));
    setPickerOpen(false);
  }

  // Apply state
  const [applying, setApplying] = useState(false);
  const [result, setResult] = useState<ApplyResultPhase[] | null>(null);

  // Post-apply health-check step. Once apply succeeds we slide the
  // drawer into a "checks" view that runs glab-sync / login-status /
  // backend monitor probes — so users see what's actually live before
  // we dismiss the wizard.
  type CheckState = { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
  // 'splash' is the cold-boot choose-tenant gate: shown when there's no
  // enterprise source yet so the user picks between adding an enterprise
  // key or continuing with the public template. Skipped entirely when
  // the wizard launches scoped to an existing source (e.g. handoff from
  // EnterpriseBadge add) or when enterprise is already configured.
  const [phase, setPhase] = useState<'splash' | 'form' | 'checks'>('form');
  const [splashAddKey, setSplashAddKey] = useState('');
  const [splashBusy, setSplashBusy] = useState(false);
  const [splashError, setSplashError] = useState('');
  const [checks, setChecks] = useState<Record<string, CheckState>>({
    glabCli: { status: 'pending' },
    loginStatus: { status: 'pending' },
    services: { status: 'pending' },
  });

  async function runChecks() {
    // glab sync — only meaningful when the user opted into gitlab.
    // Avoid hitting the API at all when gitlab isn't selected, otherwise
    // the route returns a hard error ("not installed or not enabled")
    // and the check shows red for something the user explicitly skipped.
    if (!selectedConnectors.has('gitlab')) {
      setChecks(c => ({ ...c, glabCli: {
        status: 'skipped',
        message: 'gitlab not selected — skipped',
      } }));
    } else {
      setChecks(c => ({ ...c, glabCli: { status: 'running' } }));
      fetch('/api/connectors/gitlab/sync-cli', { method: 'POST' })
        .then(r => r.json().then(j => ({ ok: r.ok && j.ok !== false, j })))
        .then(({ ok, j }) => setChecks(c => ({
          ...c,
          glabCli: {
            status: ok ? 'ok' : 'fail',
            message: ok ? 'glab CLI synced with GitLab token' : (j.error || j.stderr || 'sync failed'),
            detail: j,
          },
        })))
        .catch(e => setChecks(c => ({ ...c, glabCli: { status: 'fail', message: e.message } })));
    }

    // Login status — POST triggers fresh check across all sources.
    setChecks(c => ({ ...c, loginStatus: { status: 'running' } }));
    fetch('/api/login-status', { method: 'POST' })
      .then(r => r.json())
      .then(j => {
        const rows = j.rows || j.results || [];
        const okCount = rows.filter((r: any) => r.result?.ok).length;
        const failCount = rows.length - okCount;
        setChecks(c => ({
          ...c,
          loginStatus: {
            status: rows.length === 0 ? 'ok' : (failCount === 0 ? 'ok' : 'fail'),
            message: rows.length === 0
              ? 'no sources configured yet'
              : `${okCount}/${rows.length} sources ok` + (failCount ? `, ${failCount} need attention` : ''),
            detail: rows,
          },
        }));
      })
      .catch(e => setChecks(c => ({ ...c, loginStatus: { status: 'fail', message: e.message } })));

    // Backend processes via /api/monitor.
    setChecks(c => ({ ...c, services: { status: 'running' } }));
    fetch('/api/monitor')
      .then(r => r.json())
      .then(j => {
        const procs = j.processes || j;
        const expected = ['nextjs', 'terminal', 'workspace', 'chat'];
        const down = expected.filter(k => !procs[k]?.running);
        setChecks(c => ({
          ...c,
          services: {
            status: down.length === 0 ? 'ok' : 'fail',
            message: down.length === 0 ? 'core services running' : `down: ${down.join(', ')}`,
            detail: procs,
          },
        }));
      })
      .catch(e => setChecks(c => ({ ...c, services: { status: 'fail', message: e.message } })));
  }
  // Dirty tracking — flip true on any input touch. Guards close.
  // Suppress the first effect run (initial state hydration from API).
  const [dirty, setDirty] = useState(false);
  const initializedRef = useRef(false);
  useEffect(() => {
    if (!initializedRef.current) { initializedRef.current = true; return; }
    setDirty(true);
  }, [displayName, displayEmail, apiKey, apiBaseUrl, apiModel, apiProfileId, apiProvider,
      connectorValues, selectedConnectors, selectedPipelines, projectInput, cliAgentId, cliAgentPath]);
  // Inline marketplace sync (so user doesn't have to leave the wizard
  // to populate the pipeline list when it's empty / stale).
  const [syncingMarket, setSyncingMarket] = useState(false);
  const [syncMsg, setSyncMsg] = useState<string>('');

  async function syncMarketplaceAndReload() {
    if (syncingMarket) return;
    setSyncingMarket(true); setSyncMsg('');
    try {
      const r = await fetch('/api/workflows/marketplace', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'sync' }),
      });
      const j = await r.json();
      if (!r.ok || j.ok === false) {
        setSyncMsg(`sync failed: ${j.error || r.status}`);
        return;
      }
      // Pull fresh onboarding state (which re-computes suggested_pipelines).
      const url = buildOnboardingUrl(currentSource, currentDept);
      const s = await fetch(url).then(x => x.json()) as OnboardingState;
      setState(s);
      // Auto-select fortinet-* (existing user selections preserved).
      setSelectedPipelines(prev => {
        const next = new Set(prev);
        for (const n of s.suggested_pipelines) next.add(n);
        return next;
      });
      setSyncMsg(`synced — ${s.suggested_pipelines.length} fortinet-* pipeline(s) suggested`);
    } catch (e) {
      setSyncMsg(`sync failed: ${e instanceof Error ? e.message : String(e)}`);
    } finally { setSyncingMarket(false); }
  }

  // ─── Load state ─────────────────────────────────────────
  // detect-cli is a separate (slower) endpoint — fire it in parallel
  // so the main state arrives instantly, CLI list fills in shortly
  // after. Avoids blocking the wizard mount on `which claude` etc.
  useEffect(() => {
    // E2/E3: re-runs whenever `currentSource` or `currentDept` changes,
    // so switching either dropdown pulls a fresh template + prompts.
    // detect-cli is scope-agnostic, only re-fetch on mount.
    const url = buildOnboardingUrl(currentSource, currentDept);
    Promise.all([
      fetch(url).then(r => r.json()),
      fetch('/api/onboarding/detect-cli').then(r => r.json()).catch(() => ({ detected: [] })),
    ]).then(([s, d]: [OnboardingState, { detected: typeof s.detected_cli }]) => {
      // Merge probe results into state
      s.detected_cli = d.detected || [];
      setState(s);
      // Cold-boot gate: no enterprise sources AND no explicit scope from
      // the EnterpriseBadge handoff → show the choose-tenant splash.
      // Once user picks (add enterprise OR continue with public), we
      // flip to 'form' and the load effect re-runs with the choice
      // baked in.
      const hasEnterpriseSource = Array.isArray(s.available_sources)
        && s.available_sources.some(src => src.id !== 'public' && src.id !== 'user' && src.has_template);
      if (!hasEnterpriseSource && !currentSource && !initialSourceId) {
        setPhase('splash');
      }
      setDisplayName(s.current.displayName);
      setDisplayEmail(s.current.displayEmail);
      setSelectedPipelines(new Set(s.suggested_pipelines));

      // Default-select rules:
      //   - template ships enabled=true → on by default
      //   - user already installed it (any prior run / manual setup) →
      //     on, even if template ships enabled=false (e.g. fortincm).
      //     Otherwise re-running the wizard would silently deselect a
      //     working connector and the next Apply would skip writing
      //     fresh tokens to it.
      const defaultSelected = new Set(
        (s.template_connectors || [])
          .filter(c => c.default_enabled || c.already_installed)
          .map(c => c.id),
      );
      setSelectedConnectors(defaultSelected);

      // Pre-fill any prompt that declares a `default` (e.g. jenkins
      // instance name = "default-jenkins"). User-set values from a prior
      // run would shadow these via the "currently set" path on apply.
      const presets: Record<string, string> = {};
      for (const [key, def] of Object.entries(s.template_prompts || {})) {
        if (def?.default && !s.prompt_values_set?.[key]) {
          presets[key] = def.default;
        }
      }
      if (Object.keys(presets).length > 0) setConnectorValues(presets);

      // Pre-fill API profile from existing default (settings.chatAgent)
      if (s.current.apiProfile) {
        const a = s.current.apiProfile;
        setApiProfileId(a.id);
        setApiProvider(a.provider);
        setApiBaseUrl(a.baseUrl);
        setApiModel(a.model);
        setApiKeyExisting(a.apiKeySet);
      }

      // Pre-fill CLI agent: pick existing default, else first detected
      if (s.current.defaultAgent && s.current.agents.find(a => a.id === s.current.defaultAgent)) {
        const a = s.current.agents.find(x => x.id === s.current.defaultAgent)!;
        setCliAgentId(a.id);
        if (['claude', 'codex', 'aider', 'opencode'].includes(a.tool)) {
          setCliAgentTool(a.tool as any);
        }
        setCliAgentPath(a.path || '');
      } else if (s.detected_cli.length > 0) {
        const first = s.detected_cli[0];
        setCliAgentId(first.name);
        setCliAgentTool(first.name as any);
        setCliAgentPath(first.path);
      }
    }).catch(() => setState({
      ok: false, onboardingCompleted: false,
      current: { displayName: '', displayEmail: '', apiProfileIds: [], apiProfile: null, chatAgent: '', defaultAgent: '', agents: [], projectRoots: [] },
      detected_cli: [], template_connectors: [], template_prompts: {}, prompt_values_set: {}, prompt_targets: {}, suggested_pipelines: [],
    }));
  }, [currentSource, currentDept]);

  // Quick provider preset switch
  function setProviderPreset(key: keyof typeof PROVIDER_DEFAULTS) {
    setApiProfileId(key);
    const p = PROVIDER_DEFAULTS[key];
    setApiBaseUrl(p.baseUrl);
    setApiModel(p.model);
    setApiProvider(key === 'anthropic' ? 'anthropic' : 'openai-compatible');
  }

  async function splashAddEnterprise() {
    const key = splashAddKey.trim();
    if (!key || splashBusy) return;
    setSplashBusy(true); setSplashError('');
    try {
      const r = await fetch('/api/enterprise-keys', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ key }),
      });
      const data = await r.json();
      if (!data.ok) { setSplashError(data.error || 'failed to add key'); return; }
      // Hand off to the scoped wizard. Setting currentSource fires the
      // load effect which re-fetches the rendered template, prompts,
      // dept list etc. for the new tenant. `phase` flips to 'form' so
      // the splash is gone on next render.
      if (data.source_id) setCurrentSource(data.source_id);
      setPhase('form');
    } catch (e) {
      setSplashError(e instanceof Error ? e.message : String(e));
    } finally {
      setSplashBusy(false);
    }
  }

  function splashUsePublic() {
    setCurrentSource(null);  // priority chain → public falls through
    setPhase('form');
  }

  async function apply() {
    if (applying) return;
    // Identity is required — display name + email are referenced via
    // {user.name} / {user.email} all over the template (jenkins username,
    // gitlab commit author, etc.). Empty values produce silently-broken
    // configs that surface much later.
    const missing: string[] = [];
    if (!displayName.trim()) missing.push('Display name');
    if (!displayEmail.trim()) missing.push('Email');
    // Template-side email pattern (e.g. fortinet template restricts to
    // @fortinet.com / @fortinet-us.com / @fortinet-*.com). Skip when no
    // pattern is declared. Compile-on-demand — pattern is a string from
    // JSON so an invalid regex falls back to "accept all" rather than
    // crashing the user's Apply flow.
    const emailPatternStr = state?.template_identity?.email_pattern;
    if (displayEmail.trim() && emailPatternStr) {
      let pat: RegExp | null = null;
      try { pat = new RegExp(emailPatternStr); } catch { pat = null; }
      if (pat && !pat.test(displayEmail.trim())) {
        const hint = state?.template_identity?.email_pattern_hint;
        alert(`Email "${displayEmail.trim()}" doesn't match the required pattern.\n\n${hint || `Must match: ${emailPatternStr}`}`);
        return;
      }
    }
    // Required connector prompts (e.g. gitlab_token_name, gitlab_pat).
    // Only check when their owning connector is selected AND the value
    // isn't already saved from a prior wizard run (prompt_values_set).
    if (state) {
      const prompts = state.template_prompts || {};
      const valuesSet = state.prompt_values_set || {};
      const targets = state.prompt_targets || {};
      for (const [key, def] of Object.entries(prompts)) {
        if (!def.required) continue;
        if (valuesSet[key]) continue;                       // already saved
        const owners = targets[key] || [];
        const owningConnectorSelected = owners.length === 0
          || owners.some(t => selectedConnectors.has(t.connector));
        if (!owningConnectorSelected) continue;             // unrelated to selected connectors
        if (!(connectorValues[key] || '').trim()) {
          missing.push(def.label || key);
        }
      }
    }
    if (missing.length > 0) {
      alert(`Required field${missing.length > 1 ? 's' : ''} missing:\n\n• ${missing.join('\n• ')}`);
      return;
    }
    setApplying(true); setResult(null);
    try {
      // Strip values whose target connector got unchecked — keeping them
      // in state lets the user re-check and recover the typed value, but
      // we don't want to write tokens into connectors we're not installing.
      const filteredValues: Record<string, string> = {};
      for (const [k, v] of Object.entries(connectorValues)) {
        const targets = state?.prompt_targets?.[k] || [];
        const anyTargetSelected = targets.some(t => selectedConnectors.has(t.connector));
        if (anyTargetSelected || targets.length === 0) filteredValues[k] = v;
      }
      const payload: any = {
        identity: { displayName, displayEmail },
        connectorValues: filteredValues,
        selectedConnectors: Array.from(selectedConnectors),
        pipelines: Array.from(selectedPipelines),
        projectRoots: projectInput.split(/[\n,]/).map(s => s.trim()).filter(Boolean),
        // E2/E3: apply against the same template GET rendered. Use
        // state.resolved_* as fallback so an untouched dropdown still
        // sends what was rendered (server's default lookup matches).
        ...(currentSource || state?.resolved_source ? { sourceId: currentSource || state?.resolved_source } : {}),
        ...(currentDept || state?.resolved_dept ? { deptId: currentDept || state?.resolved_dept } : {}),
      };
      // Send apiProfile if a key was typed OR if we're re-confirming an
      // existing profile (apiKeyExisting=true and user changed any field).
      // Empty apiKey + existing → backend preserves the on-disk key.
      if (apiKey.trim() || apiKeyExisting) {
        payload.apiProfile = {
          id: apiProfileId,
          name: apiProfileId,
          provider: apiProvider,
          model: apiModel,
          baseUrl: apiBaseUrl,
          setAsDefault: apiSetAsDefault,
          ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
        };
      }
      if (cliAgentId) {
        payload.cliAgent = {
          id: cliAgentId,
          tool: cliAgentTool,
          ...(cliAgentPath ? { path: cliAgentPath } : {}),
          setAsDefault: cliSetAsDefault,
        };
      }
      const r = await fetch('/api/onboarding', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'apply', payload }),
      });
      const j = await r.json();
      setResult(j.phases || []);
      if (j.ok) {
        setDirty(false);          // applied state is canonical now
        setPhase('checks');
        runChecks();
      }
    } catch (e) {
      setResult([{ phase: 'request', ok: false, error: e instanceof Error ? e.message : String(e) }]);
    } finally { setApplying(false); }
  }

  if (!state) {
    return (
      <DrawerShell onClose={onClose}>
        <div className="p-4 text-[11px] text-[var(--text-secondary)]">Loading…</div>
      </DrawerShell>
    );
  }

  const prompts = state.template_prompts || {};
  // Group prompts by the prompt's own `group` field (if set) — else
  // fall back to the first connector target. Within a group, preserve
  // template insertion order (gives template authors control over UI
  // order; e.g. put 'gitlab token name' above 'gitlab PAT' just by
  // listing it first in _prompts).
  const promptGroups = (() => {
    const byConnector = new Map<string, string[]>();
    for (const key of Object.keys(prompts)) {
      const explicit = prompts[key]?.group;
      const targets = state.prompt_targets?.[key] || [];
      const conn = explicit || targets[0]?.connector || '_other';
      if (!byConnector.has(conn)) byConnector.set(conn, []);
      byConnector.get(conn)!.push(key);
    }
    return Array.from(byConnector.entries());
  })();

  const guardedClose = () => {
    if (!dirty || confirm('Close without saving? Your unsaved inputs will be lost.')) onClose();
  };

  if (phase === 'splash') {
    return (
      <DrawerShell onClose={onClose}>
        <div className="overflow-y-auto flex-1 p-5 space-y-4">
          <h2 className="text-sm font-medium text-[var(--text-primary)]">Welcome to Forge</h2>
          <p className="text-[11px] text-[var(--text-secondary)] leading-snug">
            Start with an enterprise template (Fortinet, etc. — pre-baked
            tokens, agents, and pipelines for your org), or continue with
            the public template (you wire each connector by hand).
          </p>

          <div className="border border-[var(--accent)]/30 bg-[var(--accent)]/5 rounded p-3 space-y-2">
            <div className="text-[11px] font-medium text-[var(--text-primary)]">🔒 Add enterprise key</div>
            <div className="text-[10px] text-[var(--text-secondary)] leading-snug">
              Paste a key like <code>fortinet:&lt;PAT&gt;</code> or the long form
              <code> &lt;PAT&gt;@github.com/org/repo</code>. After validation the
              wizard reloads scoped to that tenant.
            </div>
            <input
              type="password"
              value={splashAddKey}
              onChange={(e) => setSplashAddKey(e.target.value)}
              placeholder="enterprise key"
              autoFocus
              className={inputCls}
              onKeyDown={(e) => { if (e.key === 'Enter') splashAddEnterprise(); }}
            />
            {splashError && (
              <div className="text-[10px] text-red-400">{splashError}</div>
            )}
            <button
              onClick={splashAddEnterprise}
              disabled={splashBusy || !splashAddKey.trim()}
              className="text-[11px] px-3 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90 disabled:opacity-40"
            >
              {splashBusy ? 'Adding…' : 'Add + continue →'}
            </button>
          </div>

          <div className="text-center text-[10px] text-[var(--text-secondary)]">or</div>

          <button
            onClick={splashUsePublic}
            className="w-full text-[11px] px-3 py-2 rounded border border-[var(--border)] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
          >
            Continue with public template →
          </button>

          <p className="text-[9px] text-[var(--text-secondary)] italic">
            You can always add an enterprise key later from the badge in the top-left.
          </p>
        </div>
      </DrawerShell>
    );
  }

  if (phase === 'checks') {
    return (
      <DrawerShell onClose={onClose}>
        <div className="overflow-y-auto flex-1 p-4 space-y-4">
          <h2 className="text-sm font-medium text-[var(--text-primary)]">Setup applied — verifying</h2>
          <p className="text-[11px] text-[var(--text-secondary)]">
            Running post-setup checks. You can finish anytime; failures here usually
            just mean the corresponding token/connector wasn't configured.
          </p>

          <CheckRow
            title="GitLab → glab CLI sync"
            hint="Writes the GitLab PAT into ~/.config/glab-cli so shell `glab` matches Forge."
            state={checks.glabCli}
          />
          <CheckRow
            title="Connector login status"
            hint="Probes each configured connector source (gitlab, mantis, etc.) for live auth."
            state={checks.loginStatus}
            renderDetail={d => Array.isArray(d) ? (
              <ul className="space-y-0.5">
                {d.map((row: any, i: number) => {
                  const ok = !!row.result?.ok;
                  const ref = row.source?.refresh;
                  return (
                    <li key={i} className="font-mono flex items-center gap-1 flex-wrap">
                      <span className={ok ? 'text-emerald-500' : 'text-red-400'}>
                        {ok ? '✓' : '✗'}
                      </span>
                      <span>{row.source?.label || row.source?.id || 'source'}</span>
                      {row.result?.message && (
                        <span className="text-[var(--text-secondary)]">— {row.result.message}</span>
                      )}
                      {/* Render a direct fix link only when the row failed */}
                      {!ok && ref?.kind === 'open-url' && ref.url && (
                        <a
                          href={ref.url}
                          target="_blank"
                          rel="noopener noreferrer"
                          className="text-[var(--accent)] hover:underline ml-auto"
                          title={ref.description || ref.url}
                        >
                          ↗ login
                        </a>
                      )}
                      {!ok && ref?.kind === 'show-command' && (
                        <code
                          className="text-[var(--accent)] cursor-pointer ml-auto"
                          title={`Click to copy — ${ref.description}`}
                          onClick={() => navigator.clipboard?.writeText(ref.command)}
                        >
                          ⧉ {ref.command}
                        </code>
                      )}
                      {!ok && ref?.kind === 'open-settings' && (
                        <span className="text-[var(--text-secondary)] ml-auto">
                          (configure in Settings → {ref.section})
                        </span>
                      )}
                    </li>
                  );
                })}
              </ul>
            ) : null}
          />
          <CheckRow
            title="Backend services"
            hint="Web / terminal / workspace / chat process counts (per ps aux)."
            state={checks.services}
            renderDetail={d => d && typeof d === 'object' ? (
              <ul className="space-y-0.5">
                {Object.entries(d).map(([k, v]: [string, any]) => (
                  <li key={k} className="font-mono">
                    <span className={v?.running ? 'text-emerald-500' : 'text-red-400'}>
                      {v?.running ? '✓' : '✗'}
                    </span>{' '}
                    {k}{v?.pid ? ` (pid ${v.pid})` : ''}{v?.port ? `:${v.port}` : ''}
                  </li>
                ))}
              </ul>
            ) : null}
          />
        </div>

        <div className="border-t border-[var(--border)] p-3 flex items-center gap-2 shrink-0">
          <button
            onClick={runChecks}
            className="text-[10px] px-2.5 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--text-primary)]"
          >
            ↻ Re-run checks
          </button>
          <div className="flex-1" />
          <button
            onClick={onComplete}
            className="text-[11px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
          >
            Finish
          </button>
        </div>
      </DrawerShell>
    );
  }

  // Section numbers are computed render-time so they stay sequential even
  // when the template auto-installs API profile / agents (which hides sections
  // 2 and 3) and minimal-mode pipelines stay visible.
  let step = 0;
  const stepN = () => ++step;

  return (
    <DrawerShell onClose={guardedClose}>
      <div className="overflow-y-auto flex-1 p-4 space-y-5">
        <h2 className="text-sm font-medium text-[var(--text-primary)]">Forge Setup</h2>
        <p className="text-[11px] text-[var(--text-secondary)] -mt-3">
          Fill in once. Re-runnable from Settings → "Re-run Onboarding".
        </p>
        {/* E2: tenant selector — only renders when there's more than one
            source to pick from (typical user has 1 enterprise + public).
            `resolved_source` is highlighted with ✓; sources that haven't
            cached a template yet show greyed out with "(not synced)". */}
        {((state.available_sources && state.available_sources.filter(s => s.has_template).length > 1)
          || (state.available_departments && state.available_departments.length > 1)) && (
          <div className="flex items-center gap-2 text-[11px] -mt-2 flex-wrap">
            <span className="text-[var(--text-secondary)]">Wizard for:</span>
            {state.available_sources && state.available_sources.filter(s => s.has_template).length > 1 && (
              <select
                value={currentSource ?? (state.resolved_source ?? '')}
                onChange={(e) => {
                  // Source switch resets the dept choice — the new tenant's
                  // depts are a different namespace, so let the server pick
                  // the default again rather than mis-mapping the slug.
                  setCurrentSource(e.target.value || null);
                  setCurrentDept(null);
                }}
                className="text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
              >
                {state.available_sources.map(s => (
                  <option key={s.id} value={s.id} disabled={!s.has_template}>
                    {s.display_name}{!s.has_template ? ' (not synced)' : ''}{s.id === state.resolved_source ? ' ✓' : ''}
                  </option>
                ))}
              </select>
            )}
            {state.available_departments && state.available_departments.length > 1 && (
              <>
                <span className="text-[var(--text-secondary)]">·</span>
                <select
                  value={currentDept ?? (state.resolved_dept ?? '')}
                  onChange={(e) => setCurrentDept(e.target.value || null)}
                  className="text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-1.5 py-0.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
                >
                  {state.available_departments.map(d => (
                    <option key={d.id} value={d.id}>
                      {d.display_name}{d.id === state.resolved_dept ? ' ✓' : ''}
                    </option>
                  ))}
                </select>
              </>
            )}
            {state.template_department_name && (
              <span className="text-[10px] text-[var(--text-secondary)] italic">
                {`{dept.name} = "${state.template_department_name}"`}
              </span>
            )}
          </div>
        )}

        {/* ── Identity ─────────────────────────────────────────── */}
        <Section title={`${stepN()}. Identity`} hint="Used as your name in pipeline/connector contexts; referenceable later as {user.name} / {user.email}. Forge derives things like jenkins username from the email's local-part.">
          <Field label={<><span>Display name</span> <span className="text-red-400 font-bold ml-0.5">*</span></>}>
            <input
              className={inputCls + (displayName.trim() ? '' : ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5')}
              value={displayName}
              onChange={e => setDisplayName(e.target.value)}
              placeholder="Zhen Liu (required)"
              required
            />
          </Field>
          <Field label={<><span>Email</span> <span className="text-red-400 font-bold ml-0.5">*</span></>}>
            <input
              className={inputCls + (() => {
                if (!displayEmail.trim()) return ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5';
                const pat = state.template_identity?.email_pattern;
                if (pat) {
                  try { if (!new RegExp(pat).test(displayEmail.trim())) return ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5'; } catch {}
                }
                return '';
              })()}
              value={displayEmail}
              onChange={e => setDisplayEmail(e.target.value)}
              placeholder="zliu@fortinet.com (required)"
              required
            />
            {state.template_identity?.email_pattern && (
              <p className="text-[9px] text-[var(--text-secondary)] italic">
                {state.template_identity.email_pattern_hint
                  || `Must match: ${state.template_identity.email_pattern}`}
              </p>
            )}
          </Field>
          {state.template_derive_keys?.length ? (
            <p className="text-[9px] text-[var(--text-secondary)] italic">
              Auto-derived from your email: {state.template_derive_keys.join(', ')}.
            </p>
          ) : null}
        </Section>

        {/* ── Project roots — moved up so users address it before
            the template-installed sections (which most just glance at). */}
        <Section title={`${stepN()}. Project roots`} hint="Directories Forge can run agents in. Pick from filesystem or type/paste paths (one per line). Optional — pipelines fall back to <dataDir>/scratch when none configured.">
          {state.current.projectRoots.length > 0 ? (
            <p className="text-[10px] text-[var(--text-secondary)]">
              Existing: <span className="font-mono">{state.current.projectRoots.join(', ')}</span> (new entries appended)
            </p>
          ) : (
            <p className="text-[10px] text-amber-400/90">
              No project roots yet. Pipelines first try to auto-clone the GitLab connector's <span className="font-mono">default_project_path</span>; if that's not set or clone fails, they fall back to <span className="font-mono">&lt;dataDir&gt;/scratch</span> so they still run (worktrees land inside scratch).
            </p>
          )}
          <div className="flex items-center gap-2">
            <button
              type="button"
              onClick={() => openPicker()}
              className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
            >
              📁 Pick folder…
            </button>
            <span className="text-[9px] text-[var(--text-secondary)]">or type below</span>
          </div>
          <textarea
            rows={2}
            className={inputCls + ' font-mono'}
            value={projectInput}
            onChange={e => setProjectInput(e.target.value)}
            placeholder="/Users/you/IdeaProjects"
          />

          {pickerOpen && (
            <div className="border border-[var(--border)] rounded p-2 bg-[var(--bg-tertiary)] space-y-1">
              <div className="flex items-center gap-1 text-[10px] font-mono text-[var(--text-secondary)] truncate">
                {pickerParent && (
                  <button onClick={() => openPicker(pickerParent!)} className="text-[var(--accent)] hover:underline">..</button>
                )}
                <span className="truncate">{pickerPath || '(loading)'}</span>
                <button
                  onClick={() => setPickerOpen(false)}
                  className="ml-auto text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
                >✕</button>
              </div>
              <div className="max-h-40 overflow-y-auto space-y-0.5">
                {pickerEntries.length === 0 && (
                  <p className="text-[10px] text-[var(--text-secondary)] italic">no subdirectories</p>
                )}
                {pickerEntries.map(e => (
                  <div key={e.path} className="flex items-center gap-1 text-[10px] font-mono">
                    <button
                      onClick={() => openPicker(e.path)}
                      className="text-[var(--text-primary)] hover:text-[var(--accent)] flex-1 text-left truncate"
                      title="Drill into this folder"
                    >
                      📁 {e.name}
                    </button>
                    <button
                      onClick={() => addPickedPath(e.path)}
                      className="text-[9px] text-[var(--accent)] hover:underline px-1"
                      title="Add this folder to project roots"
                    >+ pick</button>
                  </div>
                ))}
              </div>
              {pickerPath && (
                <button
                  onClick={() => addPickedPath(pickerPath)}
                  className="text-[10px] px-2 py-0.5 bg-[var(--accent)] text-white rounded w-full"
                >
                  Use current folder: <span className="font-mono">{pickerPath}</span>
                </button>
              )}
            </div>
          )}
        </Section>

        {/* ── Template pre-baked items ─────────────────────────────
            Collapsed by default — once the template's installed there's
            nothing to do here, click to expand to inspect. */}
        {(state.template_agents_preview?.length || state.template_api_profiles_preview?.length) && (
          <Section
            title={`${stepN()}. ${state.template_enterprise_name
              ? `🔒 ${state.template_enterprise_name} — auto-installed`
              : '🔒 Template — auto-installed'}`}
            hint="The enterprise template pre-bakes these on your behalf. Tokens are encrypted in git and decrypted locally. You can edit / disable any of them in Settings after onboarding."
            defaultCollapsed
          >
            {state.template_agents_preview?.length ? (
              <div>
                <div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1">CLI agents</div>
                <ul className="space-y-0.5">
                  {state.template_agents_preview.map((a) => (
                    <li key={a.id} className="text-[11px] font-mono flex items-center gap-1.5">
                      <span className="text-emerald-500">✓</span>
                      <span className="text-[var(--text-primary)]">{a.id}</span>
                      {a.tool && <span className="text-[9px] text-[var(--text-secondary)]">{a.tool}</span>}
                      {a.has_secret && <span className="text-[9px] text-amber-500">🔐 secret</span>}
                    </li>
                  ))}
                </ul>
              </div>
            ) : null}
            {state.template_api_profiles_preview?.length ? (
              <div className="mt-2">
                <div className="text-[10px] uppercase tracking-wider text-[var(--text-secondary)] mb-1">Chat profiles</div>
                <ul className="space-y-0.5">
                  {state.template_api_profiles_preview.map((p) => (
                    <li key={p.id} className="text-[11px] font-mono flex items-center gap-1.5">
                      <span className="text-emerald-500">✓</span>
                      <span className="text-[var(--text-primary)]">{p.id}</span>
                      {p.provider && <span className="text-[9px] text-[var(--text-secondary)]">{p.provider}</span>}
                      {p.model && <span className="text-[9px] text-[var(--text-secondary)]">{p.model}</span>}
                      {p.has_secret && <span className="text-[9px] text-amber-500">🔐 secret</span>}
                    </li>
                  ))}
                </ul>
              </div>
            ) : null}
          </Section>
        )}

        {/* ── 2. API Profile (hidden when template provides one) ─
            Auto-collapse when user already has a chat profile configured. */}
        {!state.template_api_profiles_preview?.length && (
        <Section
          title={`${stepN()}. Chat API key`}
          hint="The LLM Forge's chat agent talks to. DeepSeek / Anthropic / OpenAI / Qwen / LiteLLM-compatible. Key encrypted at rest."
          defaultCollapsed={!!state.current.apiProfile || apiKeyExisting}
        >
          <div className="flex gap-1 mb-1 flex-wrap">
            {(['deepseek', 'anthropic', 'openai', 'qwen', 'litellm'] as const).map(p => (
              <button
                key={p}
                onClick={() => setProviderPreset(p)}
                className={`text-[10px] px-2 py-0.5 rounded ${apiProfileId === p ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'}`}
              >
                {p}
              </button>
            ))}
          </div>
          <Field label="Base URL">
            <input className={inputCls + ' font-mono'} value={apiBaseUrl} onChange={e => setApiBaseUrl(e.target.value)} />
          </Field>
          <Field label="Model">
            <input className={inputCls + ' font-mono'} value={apiModel} onChange={e => setApiModel(e.target.value)} />
          </Field>
          <Field label={apiKeyExisting ? `API Key (current profile "${apiProfileId}" already has a key — leave blank to keep, or paste new to overwrite)` : 'API Key (required to make chat work)'}>
            <input
              type="password"
              className={inputCls + ' font-mono'}
              value={apiKey}
              onChange={e => setApiKey(e.target.value)}
              placeholder={apiKeyExisting ? '•••••••• (keep existing)' : 'sk-...'}
            />
          </Field>
          <label className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)]">
            <input type="checkbox" checked={apiSetAsDefault} onChange={e => setApiSetAsDefault(e.target.checked)} />
            Use this profile as the default chat agent
          </label>
        </Section>
        )}

        {/* ── 2.5. CLI Agent (hidden when template provides _agents) ─
            Auto-collapse when user already has CLI agents configured. */}
        {!state.template_agents_preview?.length && (
        <Section
          title={`${stepN()}. CLI Agent`}
          hint="The CLI tool Forge launches for terminal / task sessions (claude-code / codex / aider). Detected on PATH below."
          defaultCollapsed={state.current.agents.length > 0}
        >
          {state.detected_cli.length === 0 && state.current.agents.length === 0 && (
            <p className="text-[10px] text-amber-500">
              No CLI agents detected on PATH. Install one (e.g. <code>npm i -g @anthropic-ai/claude-code</code>) and re-run onboarding.
            </p>
          )}
          {state.detected_cli.map(c => (
            <label key={c.name} className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
              <input
                type="radio"
                name="cli-agent"
                checked={cliAgentId === c.name}
                onChange={() => {
                  setCliAgentId(c.name);
                  setCliAgentTool(c.name as any);
                  setCliAgentPath(c.path);
                }}
              />
              <span className="font-mono">{c.name}</span>
              {c.version && <span className="text-[9px] text-[var(--text-secondary)]">v{c.version}</span>}
              <span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{c.path}</span>
            </label>
          ))}
          {state.current.agents.filter(a => !state.detected_cli.find(c => c.name === a.tool)).map(a => (
            <label key={a.id} className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
              <input
                type="radio"
                name="cli-agent"
                checked={cliAgentId === a.id}
                onChange={() => {
                  setCliAgentId(a.id);
                  if (['claude', 'codex', 'aider', 'opencode'].includes(a.tool)) {
                    setCliAgentTool(a.tool as any);
                  }
                  setCliAgentPath(a.path);
                }}
              />
              <span className="font-mono">{a.id}</span>
              <span className="text-[9px] text-[var(--text-secondary)]">{a.tool}</span>
              <span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{a.path}</span>
            </label>
          ))}
          {(state.detected_cli.length > 0 || state.current.agents.length > 0) && (
            <label className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)] mt-1">
              <input type="checkbox" checked={cliSetAsDefault} onChange={e => setCliSetAsDefault(e.target.checked)} />
              Use this CLI agent as the default for terminal sessions
            </label>
          )}
        </Section>
        )}

        {/* ── 3. Connectors ──────────────────────────────────────
            In minimal mode we drop the bulk selector AND filter the connector
            list to ones that still need user input — anything fully baked or
            already-set silently rides along on the pre-selected set. */}
        {(() => {
          const w = state.template_wizard;
          const minimal = !!w?.minimal;
          const hideConnectors = !!w?.hide_connectors;
          const requiredOnly = !!w?.required_only;
          const allConnectors = state.template_connectors || [];
          const promptsByKey = state.template_prompts || {};
          const valuesSet = state.prompt_values_set || {};

          // Which prompts to show for a given connector under the current
          // visibility rules. In `requiredOnly` mode: drop non-required ones,
          // but ALWAYS keep required prompts visible (set or not) so the user
          // can re-enter a password / rotate a token without leaving minimal
          // mode. The "● currently set" badge plus "leave blank to keep"
          // placeholder already communicates that they don't have to touch it.
          const visiblePromptsFor = (connectorId: string): string[] => {
            const keys = (promptGroups.find(([c]) => c === connectorId)?.[1]) || [];
            if (!requiredOnly) return keys;
            return keys.filter(k => promptsByKey[k]?.required);
          };

          // Show every connector that has SOMETHING to display — either
          // user-visible prompts or template-baked defaults. Even in
          // minimal mode we no longer hide connectors with no prompts:
          // the user explicitly wants visibility into what the template
          // installs (e.g. mantis base_url, pmdb base_url).
          const visibleConnectors = hideConnectors
            ? []
            : (minimal
              ? allConnectors.filter(({ id, has_prompts, defaults }) =>
                  (has_prompts && visiblePromptsFor(id).length > 0)
                  || (Array.isArray(defaults) && defaults.length > 0))
              : allConnectors);
          return (
        <Section
          title={`${stepN()}. Required tokens`}
          hint={minimal
            ? "Only fields you must fill in are shown. Everything else (defaults, presets) is auto-installed."
            : "Pick which connectors to install. Default = team-recommended set. Shared keys (e.g. GitLab PAT also used by Jenkins) only asked once."}
        >
          {allConnectors.length === 0 && (
            <p className="text-[10px] text-amber-500">
              No template loaded. Make sure the marketplace has been synced (Settings → Connectors → Sync).
            </p>
          )}
          {hideConnectors && allConnectors.length > 0 && visibleConnectors.length === 0 && (
            <p className="text-[10px] text-[var(--text-secondary)]">
              ✓ All connectors are pre-configured by your template. Nothing to fill in here.
            </p>
          )}
          {!hideConnectors && minimal && allConnectors.length > 0 && visibleConnectors.length === 0 && (
            <p className="text-[10px] text-[var(--text-secondary)]">
              ✓ All required tokens already set. You can re-enter values to overwrite them.
            </p>
          )}
          {/* Bulk select/deselect — hidden in minimal mode (all pre-selected) */}
          {!minimal && allConnectors.length > 0 && (
            <div className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)]">
              <button
                type="button"
                onClick={() => setSelectedConnectors(new Set(allConnectors.map(c => c.id)))}
                className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
              >Select all</button>
              <button
                type="button"
                onClick={() => setSelectedConnectors(new Set())}
                className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
              >Deselect all</button>
              <span className="ml-auto">{selectedConnectors.size}/{allConnectors.length} selected</span>
            </div>
          )}
          {visibleConnectors.map(({ id: connector, has_prompts, already_installed, defaults }) => {
            const checked = selectedConnectors.has(connector);
            const promptKeys = visiblePromptsFor(connector);
            const toggle = () => {
              setSelectedConnectors(prev => {
                const next = new Set(prev);
                if (next.has(connector)) next.delete(connector); else next.add(connector);
                return next;
              });
            };
            return (
              <div key={connector} className={`border-l-2 pl-2.5 space-y-2 ${checked ? 'border-[var(--accent)]/40' : 'border-[var(--border)] opacity-60'}`}>
                <label className="flex items-center gap-1.5 text-[11px] cursor-pointer">
                  <input type="checkbox" checked={checked} onChange={toggle} />
                  <span className="font-mono uppercase tracking-wider text-[var(--accent)]">{connector}</span>
                  {already_installed && <span className="text-[9px] text-emerald-500">● installed</span>}
                  {!has_prompts && <span className="text-[9px] text-[var(--text-secondary)]">(no tokens needed)</span>}
                </label>
                {checked && promptKeys.map(key => {
                  const p = prompts[key];
                  const v = connectorValues[key] ?? '';
                  const isSet = !!state.prompt_values_set?.[key];
                  const targets = state.prompt_targets?.[key] || [];
                  const sharedWith = targets.filter(t => t.connector !== connector);
                  return (
                    <div key={key} className="space-y-1 ml-5">
                      <label className="text-[10px] font-medium text-[var(--text-primary)] flex items-center gap-1.5 flex-wrap">
                        {p.label}
                        {p.required && !isSet && <span className="text-red-500">*</span>}
                        {!p.required && !isSet && <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
                        {isSet && <span className="text-[9px] text-emerald-500">● currently set</span>}
                        {p.url && (
                          <a href={p.url} target="_blank" rel="noopener noreferrer" className="ml-auto text-[10px] text-[var(--accent)] hover:underline" title={p.url}>
                            ↗ {p.url_label || 'Get token'}
                          </a>
                        )}
                      </label>
                      {p.hint && <p className="text-[9px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>}
                      {sharedWith.length > 0 && (
                        <p className="text-[9px] text-[var(--text-secondary)] font-mono">
                          also used by: {sharedWith.map(t => `${t.connector}.${t.field}`).join(', ')}
                        </p>
                      )}
                      <input
                        type={p.secret ? 'password' : 'text'}
                        className={inputCls + ' font-mono' + (
                          p.required && !isSet && !v.trim()
                            ? ' !border-red-500 !border-2 ring-1 ring-red-500/40 bg-red-500/5'
                            : ''
                        )}
                        value={v}
                        onChange={e => setConnectorValues({ ...connectorValues, [key]: e.target.value })}
                        placeholder={isSet ? '•••••••• (leave blank to keep current)' : (p.required ? 'required' : 'leave blank')}
                      />
                    </div>
                  );
                })}
                {/* Template-baked defaults — what lands on Apply for the
                    fields the user doesn't fill in. Distinct from the
                    italic gray hint text by being monospace + chip-style.
                    Auto-collapsed; click to expand. */}
                {checked && defaults && defaults.length > 0 && (
                  <details className="ml-5">
                    <summary className="text-[9px] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]">
                      Template defaults ({defaults.length})
                    </summary>
                    <div className="mt-1 space-y-0.5">
                      {defaults.map(d => (
                        <div key={d.field} className="flex items-baseline gap-1.5 text-[10px] font-mono leading-relaxed">
                          <span className="text-[var(--text-secondary)] shrink-0">{d.field}</span>
                          <span className="text-[var(--text-secondary)]">=</span>
                          <span className={
                            d.is_secret
                              ? 'px-1 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20 break-all'
                              : 'px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border)] break-all'
                          }>
                            {d.value}
                          </span>
                        </div>
                      ))}
                    </div>
                  </details>
                )}
              </div>
            );
          })}
        </Section>
          );
        })()}

        {/* ── Pipelines ───────────────────────────────────────── */}
        <Section title={`${stepN()}. Pipelines`} hint="Installs from the workflow marketplace. fortinet-* default-selected.">
          <div className="flex items-center gap-2 mb-1">
            <button
              type="button"
              onClick={syncMarketplaceAndReload}
              disabled={syncingMarket}
              className="text-[10px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-40"
            >
              {syncingMarket ? 'Syncing…' : '↻ Sync marketplace'}
            </button>
            {syncMsg && <span className="text-[10px] text-[var(--text-secondary)]">{syncMsg}</span>}
          </div>
          {state.suggested_pipelines.length === 0 && (
            <p className="text-[10px] text-amber-500">
              No pipelines found yet. Click <strong>Sync marketplace</strong> above to pull the latest registry.
            </p>
          )}
          {state.suggested_pipelines.map(name => (
            <label key={name} className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
              <input
                type="checkbox"
                checked={selectedPipelines.has(name)}
                onChange={e => {
                  const next = new Set(selectedPipelines);
                  if (e.target.checked) next.add(name); else next.delete(name);
                  setSelectedPipelines(next);
                }}
              />
              <span className="font-mono">{name}</span>
            </label>
          ))}
        </Section>

        {/* Project roots — moved up to step 2 (after Identity). */}

        {/* ── Memory (info only) ──────────────────────────────── */}
        <Section title={`${stepN()}. Memory`}>
          {state.memory_auto_provision ? (
            <div className="space-y-1">
              <div className="text-[11px] px-2 py-1.5 rounded border border-emerald-500/40 bg-emerald-500/10 text-emerald-300">
                <strong>Auto:</strong>{' '}
                {state.memory_auto_provision.provisioned
                  ? <>Temper account already provisioned — chat memory backed by <span className="font-mono">{state.memory_auto_provision.url}</span>.</>
                  : <>On <strong>Apply</strong>, Forge will provision a Temper account at <span className="font-mono">{state.memory_auto_provision.url}</span> using your identity above and wire chat memory to it.</>
                }
              </div>
              <p className="text-[10px] text-[var(--text-secondary)]">
                Prefer local-only memory? Switch to <strong>Local</strong> in Settings → Memory after onboarding —
                Forge falls back to a local SQLite store and your Temper credentials remain inert.
              </p>
            </div>
          ) : (
            <p className="text-[10px] text-[var(--text-secondary)]">
              Defaults to local SQLite — no setup needed. Want team-shared memory?
              Configure Temper later in Settings → Memory.
            </p>
          )}
        </Section>
      </div>

      {/* Footer */}
      <div className="border-t border-[var(--border)] p-3 flex items-center gap-2 shrink-0">
        {result && (
          <div className="flex-1 text-[10px]">
            {result.map((p, i) => (
              <span key={i} className={`mr-2 ${p.ok ? 'text-emerald-500' : 'text-red-400'}`}>
                {p.ok ? '✓' : '✗'} {p.phase}
              </span>
            ))}
          </div>
        )}
        <button
          onClick={() => { if (!dirty || confirm('Close without saving? Your unsaved inputs will be lost.')) onClose(); }}
          className="text-[10px] px-2.5 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--text-primary)]"
        >
          Close
        </button>
        <button
          onClick={async () => {
            if (dirty && !confirm('Skip setup? Your unsaved inputs will be discarded. (Banner will not appear again.)')) return;
            // action:'skip' — server only flips onboardingCompleted, does
            // NOT run any apply phases. Previously this was action:'apply'
            // with empty payload, which still wrote template _agents /
            // _apiProfiles via the connectors phase.
            await fetch('/api/onboarding', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ action: 'skip' }),
            });
            onComplete();
          }}
          className="text-[10px] px-2.5 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
          title="Mark setup as done without applying anything"
        >
          Skip
        </button>
        <button
          onClick={apply}
          disabled={applying}
          className="text-[11px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-40"
        >
          {applying ? 'Applying…' : 'Apply & Finish'}
        </button>
      </div>
    </DrawerShell>
  );
}

// ─── Drawer shell ────────────────────────────────────────────

function DrawerShell({ onClose, children }: { onClose: () => void; children: React.ReactNode }) {
  return (
    <>
      {/* Backdrop — semi-opaque, click-to-close */}
      <div className="fixed inset-0 bg-black/30 z-40" onClick={onClose} />
      {/* Drawer */}
      <div className="fixed right-0 top-0 bottom-0 w-[520px] max-w-full bg-[var(--bg-primary)] border-l border-[var(--border)] shadow-xl z-50 flex flex-col">
        {children}
      </div>
    </>
  );
}

function Section({ title, hint, children, defaultCollapsed }: { title: string; hint?: string; children: React.ReactNode; defaultCollapsed?: boolean }) {
  const [open, setOpen] = useState(!defaultCollapsed);
  const collapsible = defaultCollapsed !== undefined;
  return (
    <div className="space-y-1.5 pb-3 border-b border-[var(--border)]/40">
      <h3 className="text-[12px] font-medium text-[var(--text-primary)] flex items-center gap-1">
        {collapsible && (
          <button
            type="button"
            onClick={() => setOpen(o => !o)}
            className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] w-3 inline-flex items-center justify-center"
            title={open ? 'Collapse' : 'Expand'}
          >
            {open ? '▾' : '▸'}
          </button>
        )}
        <span
          onClick={collapsible ? () => setOpen(o => !o) : undefined}
          className={collapsible ? 'cursor-pointer flex-1' : 'flex-1'}
        >{title}</span>
      </h3>
      {open && hint && <p className="text-[10px] text-[var(--text-secondary)] leading-snug">{hint}</p>}
      {open && children}
    </div>
  );
}

function CheckRow({
  title, hint, state, renderDetail,
}: {
  title: string;
  hint?: string;
  state: { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
  renderDetail?: (d: any) => React.ReactNode;
}) {
  const [open, setOpen] = useState(false);
  const icon = state.status === 'ok' ? '✓'
    : state.status === 'fail' ? '✗'
    : state.status === 'running' ? '⟳'
    : state.status === 'skipped' ? '–'
    : '○';
  const color = state.status === 'ok' ? 'text-emerald-500'
    : state.status === 'fail' ? 'text-red-400'
    : state.status === 'running' ? 'text-amber-500'
    : state.status === 'skipped' ? 'text-[var(--text-secondary)]'
    : 'text-[var(--text-secondary)]';
  return (
    <div className="border border-[var(--border)] rounded p-2.5 space-y-1">
      <div className="flex items-center gap-2 text-[12px]">
        <span className={`${color} font-mono w-3 inline-block`}>{icon}</span>
        <span className="text-[var(--text-primary)] font-medium">{title}</span>
        {state.message && (
          <span className={`text-[10px] ${state.status === 'fail' ? 'text-red-400' : 'text-[var(--text-secondary)]'} truncate`}>
            — {state.message}
          </span>
        )}
        {state.detail && renderDetail && (
          <button onClick={() => setOpen(o => !o)} className="ml-auto text-[10px] text-[var(--accent)] hover:underline">
            {open ? 'hide' : 'details'}
          </button>
        )}
      </div>
      {hint && <p className="text-[10px] text-[var(--text-secondary)] leading-snug ml-5">{hint}</p>}
      {open && state.detail && renderDetail && (
        <div className="text-[10px] text-[var(--text-secondary)] ml-5 pt-1 border-t border-[var(--border)]/40">
          {renderDetail(state.detail)}
        </div>
      )}
    </div>
  );
}

function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
  return (
    <div className="space-y-0.5">
      <label className="text-[10px] text-[var(--text-secondary)] inline-flex items-center">{label}</label>
      {children}
    </div>
  );
}
