/**
 * Onboarding wizard API — first-run setup.
 *
 *   GET  → returns current onboarding state + suggested defaults
 *          (so the UI can pre-populate from existing settings on re-run).
 *
 *   POST { action: 'apply', payload } → atomic apply:
 *     1. identity (settings.displayName / .displayEmail)
 *     2. API profile (add/upsert apiProfiles[id] + set chatAgent)
 *     3. connectors (run the template apply path with the user-supplied values)
 *     4. pipelines (install selected pipelines from workflow marketplace)
 *     5. projects (settings.projectRoots add unique)
 *     6. flip settings.onboardingCompleted = true
 *
 *   POST { action: 'reset' } → flips onboardingCompleted=false so the
 *   wizard pops again. Doesn't undo anything else.
 *
 * Failure mode: best-effort. Each phase logs+collects its own errors and
 * keeps going so a missing API profile doesn't prevent connectors from
 * being configured. The response surfaces a per-phase status array.
 */

import { NextResponse } from 'next/server';
import { loadSettings, saveSettings } from '@/lib/settings';
import {
  getConnector,
  getInstalledConnector,
  listInstalledConnectors,
  setConnectorConfig,
  setConnectorEnabled,
  setConnectorDept,
} from '@/lib/connectors/registry';
import { installFromMarketplace, listMarketplace } from '@/lib/workflow-marketplace';
import { installFromRegistry } from '@/lib/connectors/sync';
import { resolveWizardTemplate } from '@/lib/connectors/wizard-template';
import { decryptDeep } from '@/lib/enterprise-secret';
import { provisionTemperUser } from '@/lib/memory/temper-provision';
import type { AgentEntry, ApiProfile } from '@/lib/settings';
import bundledTemplate from '@/templates/connector-config-template.json';

interface OnboardingPayload {
  identity?: {
    displayName?: string;
    displayEmail?: string;
  };
  apiProfile?: {
    id: string;                  // e.g. "deepseek"
    name?: string;
    provider: 'anthropic' | 'openai-compatible';
    model: string;
    /** Empty/omitted = keep the existing apiKey on the profile if any. */
    apiKey?: string;
    baseUrl?: string;
    /** If true, set as default chat profile. */
    setAsDefault?: boolean;
  };
  cliAgent?: {
    id: string;                  // e.g. "claude"
    tool: 'claude' | 'codex' | 'aider' | 'opencode';
    path?: string;
    setAsDefault?: boolean;
  };
  connectorValues?: Record<string, string>;     // template ${key} → value
  /** Subset of template connector ids to install. Missing/empty = install
   *  everything in the template. Unselected ids are skipped entirely —
   *  no installFromRegistry, no setConnectorConfig. Lets users opt out of
   *  Jenkins/NAC/FortiNCM etc. on first run. */
  selectedConnectors?: string[];
  pipelines?: string[];                          // marketplace names to install
  projectRoots?: string[];                       // paths to append
  /** E2: scope apply to one tenant's template (e.g. enterprise-fortinet).
   *  Missing = use the default priority chain (user → enterprise → public),
   *  matching pre-E2 behavior. The same template that GET rendered should
   *  be the one POST applies — otherwise the prompts the user filled
   *  point at fields that aren't in the applied template. */
  sourceId?: string;
  /** E3: scope further into a specific department template within the
   *  source. Missing = source's first dept as default. */
  deptId?: string;
}

// ─── Helpers (mirrors import-config-template/route.ts) ───────

const PLACEHOLDER_RE = /\$\{([a-zA-Z0-9_]+)\}/g;
// `{user.X}` resolves from settings.displayName / displayEmail at apply
// time. Done eagerly here (not at runtime via lib/plugins/templates) so
// the persisted config holds the literal value — otherwise users see
// `username: "{user.login}"` in their connector settings UI, which is
// confusing even though it would expand correctly at request time.
const USER_TOKEN_RE = /\{user\.([a-zA-Z0-9_]+)\}/g;
// E3: `{dept.name}` lets a template reference its own _department_name
// without repeating "FortiNAC" in 6 different connector defaults. Other
// dept fields can be added here as the design grows (display_name, etc.).
const DEPT_TOKEN_RE = /\{dept\.([a-zA-Z0-9_]+)\}/g;
function expandUserTokens(s: string, user: { name: string; email: string; login: string }): string {
  return s.replace(USER_TOKEN_RE, (_, field) => {
    switch (field) {
      case 'name': return user.name;
      case 'email': return user.email;
      case 'login': return user.login;
      default: return `{user.${field}}`;
    }
  });
}
function expandDeptTokens(s: string, dept: { name: string } | undefined): string {
  if (!dept) return s;
  return s.replace(DEPT_TOKEN_RE, (_, field) => {
    switch (field) {
      case 'name': return dept.name;
      default: return `{dept.${field}}`;
    }
  });
}
function substituteAll(
  value: unknown,
  values: Record<string, string>,
  user?: { name: string; email: string; login: string },
  dept?: { name: string },
): unknown {
  if (typeof value === 'string') {
    let out = value.replace(PLACEHOLDER_RE, (_, key) => values[key] ?? '');
    if (user) out = expandUserTokens(out, user);
    if (dept) out = expandDeptTokens(out, dept);
    return out;
  }
  if (Array.isArray(value)) return value.map((v) => substituteAll(v, values, user, dept));
  if (value && typeof value === 'object') {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(value)) out[k] = substituteAll(v, values, user, dept);
    return out;
  }
  return value;
}

/**
 * Merge two `type: instances` payloads (each a JSON-encoded array of
 * `{name, ...}` rows) by row name:
 *   - Same-name rows: keep existing values but FILL any empty fields
 *     from the template. Lets a re-run land a freshly-baked api_token
 *     when the previous run added the instance with an empty one.
 *   - Template rows not in existing: appended verbatim.
 *   - Existing rows not in template: kept only if any non-name field
 *     has a meaningful value (so a stale `default-jenkins` row left
 *     over from an older template — empty username, empty token —
 *     gets pruned automatically). User-added instances with real
 *     creds are preserved.
 *
 * Inputs can be either a string (the on-disk shape) or a parsed array.
 * Returns `{value, changed}` where `value` is the JSON string to write
 * back and `changed` is true iff anything got added/filled/dropped.
 */
function mergeInstancesByName(
  existing: unknown,
  fromTemplate: unknown,
  forceOverwriteByName?: Map<string, Set<string>>,
): { value: string; changed: boolean } {
  const parse = (v: unknown): any[] => {
    if (Array.isArray(v)) return v;
    if (typeof v === 'string' && v.trim()) {
      try { const j = JSON.parse(v); return Array.isArray(j) ? j : []; } catch { return []; }
    }
    return [];
  };
  const isEmpty = (v: unknown): boolean =>
    v == null
    || (typeof v === 'string' && v.trim() === '')
    || (Array.isArray(v) && v.length === 0);
  const hasAnyRealValue = (row: any): boolean => {
    if (!row || typeof row !== 'object') return false;
    for (const [k, v] of Object.entries(row)) {
      if (k === 'name') continue;
      if (!isEmpty(v)) return true;
    }
    return false;
  };
  const exRows = parse(existing);
  const tmplRows = parse(fromTemplate);
  const tmplByName = new Map<string, any>();
  for (const r of tmplRows) {
    const n = String(r?.name ?? '').trim();
    if (n) tmplByName.set(n, r);
  }

  let changed = false;
  const out: any[] = [];
  const keptNames = new Set<string>();

  for (const ex of exRows) {
    const name = String(ex?.name ?? '').trim();
    const tmpl = name ? tmplByName.get(name) : undefined;
    if (tmpl) {
      const forceFields = forceOverwriteByName?.get(name);
      // Fill empty fields from template. Existing non-empty values win,
      // EXCEPT for identity-derived fields (template's raw value contains
      // `{user.*}` / `{dept.*}` tokens) — those always re-track the user's
      // current identity so a displayEmail change refreshes jenkins
      // username, etc.
      const merged: any = { ...ex };
      for (const [k, v] of Object.entries(tmpl)) {
        if (k === 'name') continue;
        const force = forceFields?.has(k);
        if (force && !isEmpty(v) && JSON.stringify((merged as any)[k]) !== JSON.stringify(v)) {
          (merged as any)[k] = v;
          changed = true;
        } else if (isEmpty((merged as any)[k]) && !isEmpty(v)) {
          (merged as any)[k] = v;
          changed = true;
        }
      }
      out.push(merged);
      keptNames.add(name);
    } else {
      // Orphan (name not in current template) — drop. The template
      // is the canonical instance list; pre-existing rows from a
      // prior template version or auto-defaults get pruned here so
      // the user sees exactly what the active template prescribes.
      // Users who manually want a custom instance should add it
      // AFTER wizard apply — those won't get clobbered until the
      // next wizard re-run.
      changed = true;
    }
  }
  // Append template rows that didn't match any existing.
  for (const [name, row] of tmplByName) {
    if (keptNames.has(name)) continue;
    out.push(row);
    changed = true;
  }
  return { value: JSON.stringify(out), changed };
}

function isEffectivelyEmpty(v: unknown): boolean {
  if (v == null) return true;
  if (typeof v === 'string') return v.trim() === '';
  if (Array.isArray(v)) return v.length === 0;
  if (typeof v === 'object') return Object.keys(v as object).length === 0;
  return false;
}

function resolveTemplate(sourceId?: string, deptId?: string): any {
  // resolveWizardTemplate covers user upload → enterprise priority list →
  // public cache. When all those miss, the bundled JSON shipped inside
  // Forge is the final fallback so the wizard always has something to
  // work with even offline / on a fresh install.
  //
  // When sourceId is given, the resolver scopes to that single source
  // (no priority traversal). When deptId is also given, reads the
  // per-dept template (E3). If that source isn't cached, we still fall
  // back to the bundled template so the wizard renders something.
  const resolved = resolveWizardTemplate(sourceId, deptId);
  if (resolved) return resolved.template;
  // E5+: dept exists in the source index but has no template file (the
  // registry entry omitted `path`). Fall through to the public source
  // template so the user gets *something* to work with — their dept
  // identity is still recorded via installed_dept once they apply.
  if (sourceId && deptId) {
    const publicT = resolveWizardTemplate('public');
    if (publicT) return publicT.template;
  }
  return bundledTemplate;
}

// ─── Phase appliers ──────────────────────────────────────────

function applyIdentity(identity: OnboardingPayload['identity']): string | null {
  if (!identity) return null;
  const settings = loadSettings();
  if (identity.displayName != null) settings.displayName = identity.displayName;
  if (identity.displayEmail != null) settings.displayEmail = identity.displayEmail;
  saveSettings(settings);
  return null;
}

function applyApiProfile(p: OnboardingPayload['apiProfile']): string | null {
  if (!p) return null;
  if (!p.id) return 'api profile id required';
  const settings = loadSettings();
  settings.apiProfiles = settings.apiProfiles || {};
  const existing = settings.apiProfiles[p.id];
  // Empty/omitted apiKey → keep the existing one (lets users re-confirm
  // a profile through the wizard without re-typing the secret).
  const apiKey = (p.apiKey && p.apiKey.trim()) ? p.apiKey.trim() : (existing?.apiKey || '');
  if (!apiKey) return `api profile ${p.id}: apiKey required (no existing key to preserve)`;
  settings.apiProfiles[p.id] = {
    name: p.name || existing?.name || p.id,
    enabled: true,
    provider: p.provider,
    model: p.model || existing?.model || '',
    apiKey,
    ...(p.baseUrl ? { baseUrl: p.baseUrl } : (existing?.baseUrl ? { baseUrl: existing.baseUrl } : {})),
  };
  if (p.setAsDefault) settings.chatAgent = p.id;
  saveSettings(settings);
  return null;
}

function applyCliAgent(a: OnboardingPayload['cliAgent']): string | null {
  if (!a) return null;
  if (!a.id || !a.tool) return 'cli agent id and tool required';
  const settings = loadSettings();
  settings.agents = settings.agents || {};
  const existing = settings.agents[a.id] || {};
  settings.agents[a.id] = {
    ...existing,
    tool: a.tool,
    name: existing.name || a.id,
    enabled: true,
    ...(a.path ? { path: a.path } : {}),
  };
  if (a.setAsDefault) settings.defaultAgent = a.id;
  // Terminal launch (lib/claude-process.ts) reads settings.claudePath,
  // NOT agent.path. Set it explicitly when the user picked a claude
  // tool with an absolute path — otherwise Forge falls back to PATH
  // lookup and may pick up a different (e.g. conda-base) claude binary.
  if (a.tool === 'claude' && a.path && a.path.startsWith('/')) {
    settings.claudePath = a.path;
  }
  saveSettings(settings);
  return null;
}

/**
 * Run template-level pre-processing once:
 *   • _derive — populate computed `values` (e.g. user_email_local = email split @)
 *   • ent:… secret blobs — decrypted via _enterprise_name
 *   • _agents / _apiProfiles — written to settings on the side
 * Returns the (possibly decrypted) template, plus the same values map with
 * derived keys merged in. The connector-loop downstream then runs as before.
 */
function preprocessTemplate(
  rawTemplate: any,
  values: Record<string, string>,
): { template: any; values: Record<string, string>; agentsApplied: string[]; profilesApplied: string[] } {
  let template = rawTemplate;

  // _derive — same recipes as the import-config-template route
  const deriveDict = (template?._derive ?? {}) as Record<string, unknown>;
  for (const [derivedKey, spec] of Object.entries(deriveDict)) {
    if (values[derivedKey]) continue;
    let sourceKey: string;
    let op = 'auto';
    if (typeof spec === 'string') sourceKey = spec;
    else if (spec && typeof spec === 'object' && typeof (spec as any).from === 'string') {
      sourceKey = (spec as any).from;
      op = String((spec as any).op || 'auto');
    } else continue;
    const raw = values[sourceKey] ?? '';
    if (!raw) continue;
    let derived = raw;
    if (op === 'email_local' || (op === 'auto' && raw.includes('@'))) {
      derived = raw.split('@')[0];
    }
    values[derivedKey] = derived;
  }

  // Decrypt ent:… blobs everywhere in the template up front
  const enterpriseName = (template?._enterprise_name as string | undefined) || '';
  if (enterpriseName) {
    template = decryptDeep(template, enterpriseName);
  }

  // _agents — preserve existing user agents unless template entry sets _overwrite.
  //
  // Derived agents (template entry has `tool: claude` but no `path`) get the
  // missing identity fields filled in from the base agent's settings entry —
  // path, skipPermissionsFlag, cliType, requiresTTY, resumeFlag,
  // outputFormat. This produces a self-contained settings.yaml: every reader
  // just reads the agent's own row, no runtime base-inheritance gymnastics.
  // listAgents() also auto-detects claude/codex/aider, so even on first run
  // we can read settings.claude.path here.
  const agentsApplied: string[] = [];
  const agentsDict = template._agents as Record<string, any> | undefined;
  if (agentsDict && typeof agentsDict === 'object') {
    const fresh = loadSettings();
    const current = { ...(fresh.agents || {}) };
    let changed = false;
    const { listAgents } = require('../../../lib/agents');
    const detected = listAgents();
    const detectedById = new Map(detected.map((a: any) => [a.id, a]));
    // Helper — merge base-agent identity into a derived entry.
    function inheritFromBase(entry: any, toolId: string) {
      const base: any = detectedById.get(toolId);
      if (!base) return false;
      let filled = false;
      if (base.path && !entry.path) { entry.path = base.path; filled = true; }
      if (base.skipPermissionsFlag && !entry.skipPermissionsFlag) {
        entry.skipPermissionsFlag = base.skipPermissionsFlag; filled = true;
      }
      if (base.cliType && !entry.cliType) { entry.cliType = base.cliType; filled = true; }
      if (base.requiresTTY !== undefined && entry.requiresTTY === undefined) {
        entry.requiresTTY = base.requiresTTY; filled = true;
      }
      return filled;
    }
    for (const [agentId, raw] of Object.entries(agentsDict)) {
      if (!raw || typeof raw !== 'object') continue;
      const overwrite = (raw as any)._overwrite === true;
      const existing = current[agentId];
      const { _overwrite, ...templateEntry } = raw as Record<string, unknown>;
      let entry: any;
      if (existing && !overwrite) {
        // Existing entry — don't replace, but BACKFILL missing identity
        // fields (path / skipPermissionsFlag / cliType / requiresTTY) from
        // the base agent. This is how Re-run wizard heals settings.yaml
        // entries written by older wizards that didn't inherit.
        entry = { ...existing };
        const toolId = entry.tool;
        if (toolId && toolId !== agentId && inheritFromBase(entry, toolId)) {
          current[agentId] = entry as AgentEntry;
          agentsApplied.push(agentId);
          changed = true;
        }
        continue;
      }
      entry = templateEntry;
      // New entry — also inherit
      const toolId = entry.tool;
      if (toolId && toolId !== agentId) inheritFromBase(entry, toolId);
      current[agentId] = entry as AgentEntry;
      agentsApplied.push(agentId);
      changed = true;
    }
    // ALSO write detected builtins (claude/codex/aider) into settings so
    // every reader can see them via settings.agents.<id>.path directly.
    // listAgents() already auto-detects them on every call, but writing
    // them out makes settings.yaml self-documenting and lets backend code
    // that reads raw settings (without going through listAgents) work
    // correctly too.
    for (const a of detected as any[]) {
      if (!['claude', 'codex', 'aider'].includes(a.id)) continue;
      const existing = current[a.id] || {};
      const merged: any = {
        tool: existing.tool || a.id,
        name: existing.name || a.name || a.id,
        enabled: existing.enabled !== false,
        ...existing,
      };
      if (!merged.path && a.path) { merged.path = a.path; changed = true; }
      if (!merged.skipPermissionsFlag && a.skipPermissionsFlag) {
        merged.skipPermissionsFlag = a.skipPermissionsFlag; changed = true;
      }
      if (!merged.cliType && a.cliType) { merged.cliType = a.cliType; changed = true; }
      // Only write back if we actually added something new
      if (JSON.stringify(merged) !== JSON.stringify(current[a.id] || {})) {
        current[a.id] = merged;
        changed = true;
      }
    }
    if (changed) saveSettings({ ...fresh, agents: current });
  }

  // _apiProfiles — same shape rules + dedup-by-backend.
  //
  // Dedup: if the user already has an apiProfile pointing at the same
  // (baseUrl, model) tuple, skip the template entry even when the IDs
  // differ. This handles wizard-template rename scenarios — e.g. the
  // template changed `forti-k2-chat` (provider: anthropic) into
  // `fortinac-qwen-chat` (provider: litellm). Without this check we'd
  // add the renamed entry alongside the user's existing one, leaving
  // two profiles for the same backend.
  const profilesApplied: string[] = [];
  const profilesDict = template._apiProfiles as Record<string, any> | undefined;
  if (profilesDict && typeof profilesDict === 'object') {
    const fresh = loadSettings();
    const current = { ...(fresh.apiProfiles || {}) };
    let changed = false;
    let defaultPick: string | undefined;
    // Build (baseUrl|model) → existing id index for dedup lookup.
    const backendKey = (p: any): string =>
      `${String(p?.baseUrl || '').toLowerCase().replace(/\/+$/, '')}|${String(p?.model || '').toLowerCase()}`;
    const existingByBackend = new Map<string, string>();
    for (const [id, p] of Object.entries(current)) {
      const k = backendKey(p);
      if (k !== '|') existingByBackend.set(k, id);
    }
    for (const [profileId, raw] of Object.entries(profilesDict)) {
      if (!raw || typeof raw !== 'object') continue;
      const overwrite = (raw as any)._overwrite === true;
      if (current[profileId] && !overwrite) continue;
      // Skip if a different-id profile already targets the same (baseUrl, model).
      const tplKey = backendKey(raw);
      if (!overwrite && tplKey !== '|' && existingByBackend.has(tplKey)
          && existingByBackend.get(tplKey) !== profileId) {
        continue;
      }
      const { _overwrite, _default, ...entry } = raw as Record<string, unknown>;
      current[profileId] = entry as unknown as ApiProfile;
      existingByBackend.set(tplKey, profileId);
      profilesApplied.push(profileId);
      changed = true;
      // First entry flagged `_default: true` (or the first applied entry
      // when no flag is set anywhere) becomes the chat backend default —
      // but only if the user hasn't already chosen one.
      if (_default === true && !defaultPick) defaultPick = profileId;
    }
    if (changed) {
      const nextSettings = { ...fresh, apiProfiles: current };
      // Pick a default chatAgent if user has none yet. Either honour an
      // explicit `_default: true` flag, or fall back to the first applied
      // profile so chat works out of the box.
      if (!fresh.chatAgent && (defaultPick || profilesApplied[0])) {
        nextSettings.chatAgent = defaultPick || profilesApplied[0];
      }
      saveSettings(nextSettings);
    }
  }

  return { template, values, agentsApplied, profilesApplied };
}

// Exported so /api/enterprise-keys can re-apply the template's literal
// defaults (new fields added in a template update, e.g. default_job_path
// / default_project_path) after a Reinstall without forcing the user
// to re-run the entire wizard. Existing non-empty values are preserved
// — see the merge logic inside.
export async function applyConnectors(
  values: Record<string, string> | undefined,
  selectedConnectors: string[] | undefined,
  sourceId?: string,
  deptId?: string,
  opts: { forceAll?: boolean } = {},
): Promise<{
  applied: string[];
  installed_from_registry: string[];
  skipped_missing_manifest: string[];
  skipped_unselected: string[];
  fields_preserved: Array<{ connector: string; field: string }>;
  agents_applied?: string[];
  api_profiles_applied?: string[];
}> {
  const rawTemplate = resolveTemplate(sourceId, deptId);
  // Undefined = install everything; explicit [] = install nothing.
  const selected = selectedConnectors ? new Set(selectedConnectors) : null;
  // Auto-inject user identity from settings so connectors (e.g. tp.username)
  // can reference {user_name} / {user_email} without prompting again.
  // User-supplied values still win over auto-injected ones.
  const settings = loadSettings();
  const initialValues: Record<string, string> = {
    user_name: settings.displayName || '',
    user_email: settings.displayEmail || '',
    ...(values || {}),
  };
  // {user.X} ident — distinct from the legacy ${user_name} prompt key.
  // Used by substituteAll to bake displayName/login into per-connector
  // settings (e.g. Jenkins instances[].username) so the persisted config
  // holds a literal value, not the curly-token placeholder.
  //
  // Fallback chain matters: a user who only filled in Display Name in
  // the wizard (no email yet) still gets a usable login, instead of
  // ending up with Jenkins.username="". And vice versa.
  const emailLocal = (settings.displayEmail || '').split('@')[0] || '';
  const niceName = (settings.displayName && settings.displayName.trim() !== 'Forge')
    ? settings.displayName.trim() : '';
  const userIdent = {
    name: niceName || emailLocal,
    email: settings.displayEmail || '',
    login: emailLocal || niceName,
  };

  // E3: dept identity from the template's `_department_name`. Used by
  // {dept.name} substitution so templates can write
  // `default_project: "{dept.name}"` instead of hardcoding "FortiNAC".
  // E5+: template-less depts have no `_department_name` — fall back to
  // the dept index's display_name so the user's choice still flows
  // through to installed_dept + {dept.name}.
  let deptName = typeof (rawTemplate as any)?._department_name === 'string'
    ? String((rawTemplate as any)._department_name).trim() : '';
  if (!deptName && sourceId && deptId) {
    try {
      const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
      const match = listSourceDepartments(sourceId).find(d => d.id === deptId);
      if (match) deptName = match.display_name;
    } catch {}
  }
  const deptIdent = deptName ? { name: deptName } : undefined;

  // _derive + ent: decryption + _agents/_apiProfiles apply happens here,
  // regardless of which connectors the user selected. (Agents and API
  // profiles belong to the user as a whole, not to a specific connector.)
  const { template, values: subst, agentsApplied, profilesApplied } =
    preprocessTemplate(rawTemplate, initialValues);
  const applied: string[] = [];
  const installedFromRegistry: string[] = [];
  const missing: string[] = [];
  const skippedUnselected: string[] = [];
  const preserved: Array<{ connector: string; field: string }> = [];

  for (const [id, row] of Object.entries(template)) {
    if (id.startsWith('_')) continue;     // metadata keys
    if (selected && !selected.has(id)) { skippedUnselected.push(id); continue; }
    let def = getConnector(id);
    if (!def) {
      // Manifest not on disk yet — fetch from forge-connectors registry
      // and install. installFromRegistry auto-syncs if cache is missing.
      const r = await installFromRegistry(id);
      if (r.ok) {
        installedFromRegistry.push(id);
        def = getConnector(id);
      }
      if (!def) { missing.push(id); continue; }
    }

    const templateConfig = (row as any)?.config ?? {};
    const enabledTemplate = (row as any)?.enabled;
    const existing = getInstalledConnector(id)?.config ?? {};
    const merged: Record<string, unknown> = { ...existing };

    for (const [field, rawVal] of Object.entries(templateConfig)) {
      const resolved = substituteAll(rawVal, subst, userIdent, deptIdent);
      const existingVal = existing[field];
      const fieldDef = (def.settings as any)?.[field];

      // `type: instances` (multi-row settings, e.g. Jenkins instances)
      // gets row-by-row merge by `name`. Existing rows are preserved
      // (passwords stay, user edits stay); template rows whose name
      // isn't already present get added. This means a template can
      // bake a recommended instance (fortinac-jenkins / zliu) without
      // clobbering whatever the user already configured.
      if (fieldDef?.type === 'instances') {
        // Two force-overwrite triggers per row+field:
        //  (a) Reinstall (`opts.forceAll`): every template field is canonical —
        //      this is what "Reinstall" means semantically (make config match
        //      template exactly, drop user drift).
        //  (b) Identity-derived fields ({user.*} / {dept.*} tokens in raw
        //      template): re-track the user's current identity even on a
        //      normal Apply, so displayEmail changes refresh derived values
        //      like jenkins username.
        const forceMap = new Map<string, Set<string>>();
        const rawRows = Array.isArray(rawVal) ? rawVal : [];
        for (const r of rawRows) {
          if (!r || typeof r !== 'object') continue;
          const rowName = String((r as any).name ?? '').trim();
          if (!rowName) continue;
          const flagged = new Set<string>();
          for (const [k, v] of Object.entries(r as Record<string, unknown>)) {
            if (k === 'name') continue;
            if (opts.forceAll) flagged.add(k);
            else if (typeof v === 'string' && /\{(?:user|dept)\.[a-zA-Z_]+\}/.test(v)) flagged.add(k);
          }
          if (flagged.size > 0) forceMap.set(rowName, flagged);
        }
        const mergedRows = mergeInstancesByName(existingVal, resolved, forceMap);
        if (mergedRows.changed) merged[field] = mergedRows.value;
        else if (JSON.stringify(existingVal) !== JSON.stringify(resolved)) {
          preserved.push({ connector: id, field });
        }
        continue;
      }

      // Existing values that still carry `${...}` placeholders are
      // half-applied from an earlier wizard run (e.g. a template
      // referenced ${jenkins_username} before we baked 'zliu' as the
      // default). Treat them as empty so the new template wins —
      // otherwise a re-run preserves the broken half instead of fixing
      // it. `TODO_` is the legacy placeholder convention.
      const existingHasStalePlaceholder = typeof existingVal === 'string'
        && /\$\{[a-zA-Z0-9_]+\}/.test(existingVal);
      const existingNonEmpty = !isEffectivelyEmpty(existingVal)
        && !(typeof existingVal === 'string' && existingVal.startsWith('TODO_'))
        && !existingHasStalePlaceholder;
      if (existingNonEmpty) {
        if (JSON.stringify(existingVal) !== JSON.stringify(resolved)) {
          preserved.push({ connector: id, field });
        }
        continue;
      }
      merged[field] = resolved;
    }

    setConnectorConfig(id, merged);
    applied.push(id);
    if (typeof enabledTemplate === 'boolean') setConnectorEnabled(id, enabledTemplate);
    // E3: remember which dept's template owns this row so a future
    // wizard re-run lands on the same dept by default. Unsetting when
    // there's no _department_name (public/single-template tier) keeps
    // the row honest.
    setConnectorDept(id, deptIdent?.name);
  }

  // Stamp company + dept onto the user profile so Settings → Identity
  // and the Dashboard prefix show "Fortinet · FortiADC" without having
  // to scan installed_dept off every connector row.
  //
  // Source for company:
  //   1. The enterprise source's display_name (e.g. "Fortinet") when
  //      sourceId is known — case-correct, matches the dropdown's
  //      option values.
  //   2. Fallback to the template's _enterprise_name (e.g. "fortinet")
  //      which may be lower-case from the template author.
  try {
    let companyName = '';
    if (sourceId) {
      try {
        const { listEnterpriseSourceMetas } = await import('@/lib/connectors/sync');
        const src = listEnterpriseSourceMetas().find(m => m.id === sourceId);
        if (src?.display_name) companyName = src.display_name;
      } catch {}
    }
    if (!companyName && typeof (rawTemplate as any)?._enterprise_name === 'string') {
      companyName = String((rawTemplate as any)._enterprise_name).trim();
    }
    if (companyName || deptName) {
      const s = loadSettings();
      if (companyName) s.company = companyName;
      if (deptName) s.dept = deptName;
      saveSettings(s);
    }
  } catch (e) {
    console.warn('[onboarding] profile stamp failed:', (e as Error).message);
  }

  return {
    applied,
    installed_from_registry: installedFromRegistry,
    skipped_missing_manifest: missing,
    skipped_unselected: skippedUnselected,
    fields_preserved: preserved,
    agents_applied: agentsApplied.length ? agentsApplied : undefined,
    api_profiles_applied: profilesApplied.length ? profilesApplied : undefined,
  };
}

/**
 * Install the pipelines listed in the wizard template's `_pipelines: [name…]`
 * block. Used by Reinstall so connectors and pipelines have the same default
 * behavior — both auto-land from the template when the user re-pulls. If the
 * template omits `_pipelines`, falls back to `suggested_pipelines` (the
 * marketplace's fortinet-* prefix heuristic).
 */
export async function applyTemplatePipelines(sourceId?: string, deptId?: string): Promise<{
  installed: string[];
  errors: Array<{ name: string; error: string }>;
}> {
  const tpl = resolveTemplate(sourceId, deptId) as any;
  let names: string[] | undefined = Array.isArray(tpl?._pipelines)
    ? (tpl._pipelines as any[]).filter((n): n is string => typeof n === 'string' && !!n)
    : undefined;
  if (!names || names.length === 0) {
    try {
      const m = listMarketplace();
      names = (m.pipelines || [])
        .filter((e: any) => e.name?.startsWith('fortinet-'))
        .map((e: any) => e.name);
    } catch { names = []; }
  }
  return applyPipelines(names);
}

/**
 * Auto-provision a per-user Temper memory account.
 *
 * Template-driven, one-shot. The wizard template's `_temperAdmin: { url,
 * token: 'ent:…' }` block (encrypted with the enterprise key) is decrypted
 * here and used to call Temper's `/v1/onboarding/provision`. The admin
 * token NEVER persists to settings — it's a wizard-only side door for
 * initialization. Different depts can omit the block to opt out, or swap
 * for their own provisioning hooks later.
 *
 * Persisted on success: `temperUrl` + `temperKey` (the user's mk_… key) +
 * `temperNamespace` (= org_slug). `memoryBackend` stays whatever the user
 * picked (default 'auto' Temper-when-URL+key).
 *
 * Re-run is a no-op when `temperKey` is already set — re-provisioning a
 * user requires clearing the key first via Settings → Memory.
 */
async function applyTemperAutoProvision(
  sourceId: string | undefined,
  deptId: string | undefined,
): Promise<Record<string, unknown>> {
  const rawTpl = resolveTemplate(sourceId, deptId) as any;
  const entName = typeof rawTpl?._enterprise_name === 'string' ? rawTpl._enterprise_name : '';
  const decryptedTpl = entName ? (decryptDeep(rawTpl, entName) as any) : rawTpl;
  const tplAdmin = (decryptedTpl?._temperAdmin ?? {}) as { url?: string; token?: string };

  const adminToken = typeof tplAdmin.token === 'string' ? tplAdmin.token : '';
  const adminUrl = typeof tplAdmin.url === 'string' ? tplAdmin.url : '';

  if (!adminToken || !adminUrl) {
    return { skipped: 'no_admin_in_template' };
  }

  const settings = loadSettings();
  if (settings.temperKey) {
    return { skipped: 'already_provisioned' };
  }

  const email = (settings.displayEmail || '').trim();
  const company = (settings.company || '').trim();
  const dept = (settings.dept || '').trim();
  if (!email || !company || !dept) {
    return {
      skipped: 'identity_incomplete',
      missing: { email: !email, company: !company, dept: !dept },
    };
  }

  const username = email.split('@')[0];
  const niceName = settings.displayName && settings.displayName.trim() !== 'Forge'
    ? settings.displayName.trim()
    : username;

  const result = await provisionTemperUser({
    url: adminUrl,
    adminToken,
    username,
    email,
    company,
    dept,
    displayName: niceName,
  });

  if (!result.ok) {
    // 409 = user already exists in Temper. We don't have their existing
    // mk_ key (the admin endpoint mints on first-create only), so the
    // Forge admin has to recover it out-of-band. Don't fail the wizard
    // — just surface the conflict.
    return {
      error: result.error,
      status: result.status,
      ...(result.conflict ? { conflict: true } : {}),
    };
  }

  // Persist user-scoped creds only. Admin token stays out of settings —
  // re-runs read it from the template again on demand.
  const s2 = loadSettings();
  s2.temperUrl = adminUrl;
  s2.temperKey = result.api_key;
  s2.temperNamespace = result.org_slug || s2.temperNamespace || '';
  saveSettings(s2);

  return {
    provisioned: true,
    user_id: result.user_id,
    username: result.username,
    org_slug: result.org_slug,
    group_slug: result.group_slug,
  };
}

async function applyPipelines(names: string[] | undefined): Promise<{
  installed: string[];
  errors: Array<{ name: string; error: string }>;
}> {
  if (!names || names.length === 0) return { installed: [], errors: [] };
  const installed: string[] = [];
  const errors: Array<{ name: string; error: string }> = [];
  for (const name of names) {
    try {
      const r = await installFromMarketplace('pipeline', name, { overwrite: false });
      if (r.ok) installed.push(name);
      else errors.push({ name, error: r.error || 'unknown' });
    } catch (e) {
      errors.push({ name, error: e instanceof Error ? e.message : String(e) });
    }
  }
  return { installed, errors };
}

function applyProjectRoots(paths: string[] | undefined): string | null {
  if (!paths || paths.length === 0) return null;
  const settings = loadSettings();
  const existing = new Set(settings.projectRoots || []);
  for (const p of paths) {
    const trimmed = p.trim();
    if (trimmed) existing.add(trimmed);
  }
  settings.projectRoots = Array.from(existing);
  saveSettings(settings);
  return null;
}

// ─── Routes ──────────────────────────────────────────────────

export async function GET(req: Request) {
  const settings = loadSettings();
  // E2: optional ?source_id=<id> scopes the wizard to a single tenant's
  // template. Without it, the priority chain (user → enterprise → public)
  // resolves the default. The UI passes source_id via the tenant
  // selector at the top of the wizard, or after a fresh add-key handoff.
  const url = new URL(req.url);
  let requestedSourceId = (url.searchParams.get('source_id') || '').trim() || undefined;
  // E3: dept selector scopes further into a specific department template
  // when the source ships `wizard_templates: [...]`. Omitted = use the
  // source's first dept as default (resolveWizardTemplate handles).
  let requestedDeptId = (url.searchParams.get('dept') || '').trim() || undefined;

  // Profile defaults: if the URL doesn't pin a source/dept, treat
  // settings.company + settings.dept as the requested scope. Matches
  // by display_name (case-insensitive). No match → leave undefined and
  // the priority chain takes over (which lands on public when nothing
  // else fits). Simple, no inference, no derivation.
  if (!requestedSourceId && settings.company) {
    try {
      const { listEnterpriseSourceMetas } = await import('@/lib/connectors/sync');
      const want = settings.company.trim().toLowerCase();
      const m = listEnterpriseSourceMetas().find(s => s.display_name.toLowerCase() === want);
      if (m) requestedSourceId = m.id;
    } catch {}
  }
  if (!requestedDeptId && requestedSourceId && settings.dept) {
    try {
      const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
      const want = settings.dept.trim().toLowerCase();
      const d = listSourceDepartments(requestedSourceId).find(x => x.display_name.toLowerCase() === want);
      if (d) requestedDeptId = d.id;
    } catch {}
  }

  // First-launch race fix: if the user has enterprise sources configured
  // but none of them has a wizard-template.json cached yet, sync now
  // (synchronously) before resolving the template. Otherwise the wizard
  // would render against the public/bundled template with all the wrong
  // defaults — exactly the issue users hit when they boot a fresh data
  // dir with --enterprise-key set. Best-effort: any sync failure (PAT
  // wrong, network down) just falls through to the existing fallback
  // chain so the wizard at least loads.
  try {
    const { listEnterpriseSourceMetas, syncRegistry } = await import('@/lib/connectors/sync');
    const { existsSync } = await import('node:fs');
    const { join } = await import('node:path');
    const { getDataDir } = await import('@/lib/dirs');
    const sources = listEnterpriseSourceMetas();
    // E3: a source counts as cached if EITHER the legacy single
    // wizard-template.json OR the multi-dept wizards/_index.json is
    // on disk. Without this second check, fortinet (which post-E5
    // ships only wizards/) would re-trigger sync on every onboarding
    // GET because the legacy file never lands.
    const anyCached = sources.some((s: any) => {
      const base = join(getDataDir(), 'connectors', 'sources', s.id);
      return existsSync(join(base, 'wizard-template.json'))
        || existsSync(join(base, 'wizards', '_index.json'));
    });
    if (sources.length > 0 && !anyCached) {
      console.log('[onboarding] first-run enterprise sync (pulling templates)…');
      await syncRegistry({ refreshInstalled: false });
    }
  } catch (e) {
    console.warn('[onboarding] pre-render sync skipped:', (e as Error).message);
  }

  const template = resolveTemplate(requestedSourceId, requestedDeptId);

  // Expose the list of source ids the UI can switch between, so the
  // wizard's tenant selector knows what's available. 'public' is always
  // present implicitly; user override (config-template.json) shows only
  // when present on disk.
  const availableSources: Array<{ id: string; display_name: string; has_template: boolean }> = [];
  try {
    const { listEnterpriseSourceMetas } = await import('@/lib/connectors/sync');
    const { existsSync } = await import('node:fs');
    const { join } = await import('node:path');
    const { getDataDir } = await import('@/lib/dirs');
    const dd = getDataDir();
    // Either shape counts as cached — legacy single file OR the
    // multi-dept index (E3). Without this, sources that ship only
    // wizards/ get marked has_template:false and disappear from the
    // wizard's tenant selector.
    const hasCache = (id: string) => {
      const base = join(dd, 'connectors', 'sources', id);
      return existsSync(join(base, 'wizard-template.json'))
        || existsSync(join(base, 'wizards', '_index.json'));
    };
    for (const s of listEnterpriseSourceMetas()) {
      availableSources.push({ id: s.id, display_name: s.display_name, has_template: hasCache(s.id) });
    }
    availableSources.push({ id: 'public', display_name: 'Public', has_template: hasCache('public') });
    if (existsSync(join(dd, 'config-template.json'))) {
      availableSources.push({ id: 'user', display_name: 'User override', has_template: true });
    }
  } catch (e) {
    console.warn('[onboarding] listing sources failed:', (e as Error).message);
  }
  // Which source actually fed the rendered template — null when neither
  // the priority chain nor the requested id resolved (caller showed the
  // bundled fallback).
  const resolvedSource = resolveWizardTemplate(requestedSourceId, requestedDeptId)?.source ?? null;

  // E3: dept list for the scoped source (when the user picked one or
  // the chain resolved to one). The wizard renders the second-tier
  // dropdown from this. Empty list = source has only a single template,
  // no dept picker shown.
  let availableDepartments: Array<{ id: string; display_name: string }> = [];
  let resolvedDept: string | null = null;
  try {
    const { listSourceDepartments } = await import('@/lib/connectors/wizard-template');
    const scopedSource = requestedSourceId || resolvedSource;
    if (scopedSource && scopedSource !== 'user') {
      availableDepartments = listSourceDepartments(scopedSource);
      if (availableDepartments.length > 0) {
        if (requestedDeptId && availableDepartments.some(d => d.id === requestedDeptId)) {
          resolvedDept = requestedDeptId;
        } else {
          // Default to the user's profile dept (settings.dept) so the
          // wizard re-opens on the last template they picked. Falls
          // through to installed_dept stamp, then to entry #0.
          const profileDept = (settings.dept || '').trim();
          const stampDept = listInstalledConnectors()
            .map(c => c.installed_dept)
            .find((d): d is string => typeof d === 'string' && !!d);
          const lookup = profileDept || stampDept;
          const matched = lookup
            ? availableDepartments.find(d => d.display_name === lookup || d.id === lookup.toLowerCase())
            : undefined;
          resolvedDept = matched ? matched.id : availableDepartments[0].id;
        }
      }
    }
  } catch (e) {
    console.warn('[onboarding] listing depts failed:', (e as Error).message);
  }

  // Suggest fortinet-* pipelines from the marketplace as default-selected.
  let suggested_pipelines: string[] = [];
  try {
    const m = listMarketplace();
    suggested_pipelines = (m.pipelines || [])
      .filter((e: any) => e.name?.startsWith('fortinet-'))
      .map((e: any) => e.name);
  } catch { /* marketplace may be unsynced — ok */ }

  // Surface the current chat API profile for prefill — key never leaves
  // the server (we just signal whether one is set so the UI can show
  // 'leave blank to keep current').
  const profiles = settings.apiProfiles || {};
  const currentChatId = settings.chatAgent || Object.keys(profiles)[0] || '';
  const currentChat = profiles[currentChatId];
  const apiProfileDetail = currentChat ? {
    id: currentChatId,
    name: currentChat.name || currentChatId,
    provider: currentChat.provider,
    model: currentChat.model || '',
    baseUrl: currentChat.baseUrl || '',
    apiKeySet: !!currentChat.apiKey,
  } : null;

  // Existing CLI agents (settings.agents — CLI subprocess wrappers).
  const agents = settings.agents || {};
  const agentList = Object.entries(agents).map(([id, a]) => ({
    id,
    name: a.name || id,
    tool: a.tool || '',
    path: a.path || '',
    enabled: a.enabled !== false,
  }));

  // CLI detection moved to /api/onboarding/detect-cli (async, called by
  // the wizard when the drawer opens). Doing 3 × execSync('which …')
  // here was blocking the Node event loop for up to 15s on misses,
  // which froze Dashboard mount + every other API on the same worker.
  const detected: Array<{ name: string; path: string; version: string }> = [];

  // Connector list from the template (excluding `_*` metadata keys),
  // in template order. UI renders this as checkboxes — default all
  // selected, user can opt out before Apply. `default_enabled` mirrors
  // the template's `enabled:` (e.g. fortincm is opt-in).
  //
  // `defaults` exposes the template's baked field values so the wizard
  // can show them before Apply (otherwise users only see what the
  // template ships *after* installation in Settings). Rules:
  //   - skip ${...} placeholder values (those go through the prompt
  //     inputs in section 3, no point listing them twice)
  //   - mask secrets: field type=secret OR value starts with ent:/enc:
  //   - resolve {user.*} + {dept.*} so users see the literal that will
  //     land, e.g. "username: zliu" not "username: {user.login}"
  //   - flatten `type: instances` rows into "<name>.<field>" entries
  const previewUserIdent = (() => {
    const emailLocal = (settings.displayEmail || '').split('@')[0] || '';
    const niceName = (settings.displayName && settings.displayName.trim() !== 'Forge')
      ? settings.displayName.trim() : '';
    return {
      name: niceName || emailLocal,
      email: settings.displayEmail || '',
      login: emailLocal || niceName,
    };
  })();
  const previewDeptIdent = (() => {
    const n = typeof (template as any)?._department_name === 'string'
      ? String((template as any)._department_name).trim() : '';
    return n ? { name: n } : undefined;
  })();
  function previewResolve(raw: unknown): string {
    if (typeof raw !== 'string') return String(raw);
    let out = raw.replace(/\$\{[a-zA-Z0-9_]+\}/g, ''); // strip leftover prompts
    out = out.replace(/\{user\.(name|email|login)\}/g, (_, f) => (previewUserIdent as any)[f] || '');
    if (previewDeptIdent) out = out.replace(/\{dept\.name\}/g, previewDeptIdent.name);
    return out;
  }
  function isPlaceholder(v: unknown): boolean {
    return typeof v === 'string' && /\$\{[a-zA-Z0-9_]+\}/.test(v);
  }
  function isMaskedSecret(v: unknown): boolean {
    return typeof v === 'string' && (v.startsWith('ent:') || v.startsWith('enc:'));
  }

  const templateConnectors: Array<{
    id: string;
    default_enabled: boolean;
    has_prompts: boolean;
    already_installed: boolean;
    defaults: Array<{ field: string; value: string; is_secret: boolean }>;
  }> = [];
  for (const [connId, row] of Object.entries(template)) {
    if (connId.startsWith('_')) continue;
    const cfg = (row as any)?.config ?? {};
    const hasPrompts = Object.values(cfg).some(v => isPlaceholder(v));

    const def = getConnector(connId);
    const defaults: Array<{ field: string; value: string; is_secret: boolean }> = [];
    for (const [field, rawVal] of Object.entries(cfg)) {
      if (isPlaceholder(rawVal)) continue;
      const fieldDef = (def?.settings as any)?.[field];
      const declaredSecret = fieldDef?.type === 'secret';
      if (fieldDef?.type === 'instances') {
        let rows: any[] = [];
        if (Array.isArray(rawVal)) rows = rawVal;
        else if (typeof rawVal === 'string') {
          try { rows = JSON.parse(rawVal); } catch { rows = []; }
        }
        for (const inst of rows) {
          if (!inst || typeof inst !== 'object') continue;
          const name = String((inst as any).name || '<unnamed>');
          for (const [k, v] of Object.entries(inst as Record<string, unknown>)) {
            if (k === 'name') continue;
            if (isPlaceholder(v)) continue;
            const instFieldSecret = name.toLowerCase().includes('token') || k.toLowerCase().includes('token') || k.toLowerCase().includes('password') || isMaskedSecret(v);
            const valueStr = isMaskedSecret(v) ? '••••••••' : previewResolve(v);
            defaults.push({ field: `${name}.${k}`, value: valueStr, is_secret: instFieldSecret });
          }
        }
        continue;
      }
      const isSecret = declaredSecret || isMaskedSecret(rawVal);
      const valueStr = isSecret ? '••••••••' : previewResolve(rawVal);
      defaults.push({ field, value: valueStr, is_secret: isSecret });
    }

    templateConnectors.push({
      id: connId,
      default_enabled: (row as any)?.enabled !== false,
      has_prompts: hasPrompts,
      already_installed: !!getInstalledConnector(connId),
      defaults,
    });
  }

  // Per-prompt "already set" probe — for each ${key} the template uses,
  // find which connector field references it and check if that field
  // is already non-empty. Lets the wizard show "•••• (keep existing)"
  // instead of asking the user to re-type tokens.
  const promptValuesSet: Record<string, boolean> = {};
  const promptTargets: Record<string, Array<{ connector: string; field: string }>> = {};
  const placeholderRe = /\$\{([a-zA-Z0-9_]+)\}/g;
  for (const [connId, row] of Object.entries(template)) {
    if (connId.startsWith('_')) continue;
    const cfg = (row as any)?.config ?? {};
    const inst = getInstalledConnector(connId);
    const existingCfg = inst?.config || {};
    for (const [field, rawVal] of Object.entries(cfg)) {
      if (typeof rawVal !== 'string') continue;
      const matches = [...rawVal.matchAll(placeholderRe)];
      for (const m of matches) {
        const key = m[1];
        if (!promptTargets[key]) promptTargets[key] = [];
        promptTargets[key].push({ connector: connId, field });
        // "set" = the target field already has a real (non-empty, non-TODO_) value.
        const existing = existingCfg[field];
        const isSet = typeof existing === 'string'
          ? (existing.trim() !== '' && !existing.startsWith('TODO_') && !existing.includes('${'))
          : (existing != null && !isEffectivelyEmpty(existing));
        if (isSet) promptValuesSet[key] = true;
      }
    }
  }

  // ─── Template previews — what _agents / _apiProfiles / _derive will do ──
  //
  // Lets the wizard collapse hardcoded "Chat API key" / "CLI Agent" sections
  // when the template provides them, and show a one-glance "this is what
  // will be installed" banner.
  const agentsPreview: Array<{ id: string; tool?: string; has_secret: boolean }> = [];
  if (template._agents && typeof template._agents === 'object') {
    for (const [id, raw] of Object.entries(template._agents as Record<string, any>)) {
      const env = (raw?.env ?? {}) as Record<string, unknown>;
      const has_secret = Object.values(env).some((v) => typeof v === 'string' && v.startsWith('ent:'));
      agentsPreview.push({ id, tool: raw?.tool, has_secret });
    }
  }
  const apiProfilesPreview: Array<{ id: string; provider?: string; model?: string; has_secret: boolean }> = [];
  if (template._apiProfiles && typeof template._apiProfiles === 'object') {
    for (const [id, raw] of Object.entries(template._apiProfiles as Record<string, any>)) {
      const has_secret = typeof raw?.apiKey === 'string' && raw.apiKey.startsWith('ent:');
      apiProfilesPreview.push({ id, provider: raw?.provider, model: raw?.model, has_secret });
    }
  }
  const deriveKeys = template._derive ? Object.keys(template._derive as object) : [];

  // Memory auto-provision hint — pulled exclusively from the template's
  // `_temperAdmin: { url, token }` block. UI uses this to render the
  // "memory will be auto-provisioned via Temper" banner. Admin token never
  // leaves the server; only `url` is exposed. `provisioned` reflects whether
  // the user already has a personal temperKey (= Apply is a no-op).
  const tplAdmin = (template._temperAdmin ?? {}) as { url?: string; token?: unknown };
  const adminUrl = typeof tplAdmin.url === 'string' ? tplAdmin.url : '';
  const adminTokenPresent = !!(tplAdmin.token && (typeof tplAdmin.token === 'string' || typeof tplAdmin.token === 'object'));
  const memoryAutoProvision = adminTokenPresent && !!adminUrl ? {
    url: adminUrl,
    provisioned: !!settings.temperKey,
  } : undefined;

  // Wizard layout knobs the template can flip to hide noise. `minimal`
  // collapses everything but Identity + required prompts + preview;
  // useful for enterprise templates that bake nearly all defaults.
  const wizardKnobs = (template._wizard ?? {}) as {
    minimal?: boolean;
    hide_connectors?: boolean;
    hide_pipelines?: boolean;
    required_only?: boolean;
  };
  // `minimal` implies hiding pipelines and filtering to required-only prompts,
  // but NOT hiding all connector cards — required prompts still need to be
  // visible so the user can enter their gitlab PAT / jenkins token. The
  // wizard UI already drops cards that have zero user-visible prompts under
  // required_only, so explicit `hide_connectors` is only for the rare case
  // where the template literally has no required prompts.
  const wizardLayout = {
    minimal: !!wizardKnobs.minimal,
    hide_connectors: !!wizardKnobs.hide_connectors,
    hide_pipelines: !!(wizardKnobs.hide_pipelines ?? wizardKnobs.minimal),
    required_only: !!(wizardKnobs.required_only ?? wizardKnobs.minimal),
  };

  return NextResponse.json({
    ok: true,
    onboardingCompleted: !!settings.onboardingCompleted,
    current: {
      displayName: settings.displayName || '',
      displayEmail: settings.displayEmail || '',
      apiProfileIds: Object.keys(profiles),
      apiProfile: apiProfileDetail,
      chatAgent: settings.chatAgent || '',
      defaultAgent: settings.defaultAgent || '',
      agents: agentList,
      projectRoots: settings.projectRoots || [],
    },
    detected_cli: detected,
    template_connectors: templateConnectors,
    template_prompts: template._prompts || {},
    template_agents_preview: agentsPreview.length ? agentsPreview : undefined,
    template_api_profiles_preview: apiProfilesPreview.length ? apiProfilesPreview : undefined,
    template_derive_keys: deriveKeys.length ? deriveKeys : undefined,
    template_enterprise_name: typeof template._enterprise_name === 'string' ? template._enterprise_name : undefined,
    template_wizard: wizardLayout,
    memory_auto_provision: memoryAutoProvision,
    prompt_values_set: promptValuesSet,
    prompt_targets: promptTargets,
    suggested_pipelines,
    // E2: tenant scope info — `available_sources` powers the wizard's
    // top-bar tenant selector; `resolved_source` is the source id that
    // actually fed the rendered template (used to highlight the active
    // entry).
    available_sources: availableSources,
    resolved_source: resolvedSource,
    requested_source_id: requestedSourceId ?? null,
    // E3: per-dept list for the scoped source. Wizard renders second
    // dropdown from this; empty = single-template source, no picker.
    available_departments: availableDepartments,
    resolved_dept: resolvedDept,
    requested_dept_id: requestedDeptId ?? null,
    // Template's self-declared dept name (when present) — used as the
    // {dept.name} substitution and surfaced in the UI breadcrumb.
    template_department_name:
      typeof template._department_name === 'string' ? template._department_name : undefined,
    // Per-template identity validation. Currently just an email regex —
    // e.g. `_identity.email_pattern: "^[^@]+@fortinet(-[a-z]+)?\\.com$"`
    // lets the fortinac template enforce @fortinet.com / @fortinet-us.com
    // suffixes. Surfaced raw so the client can show the pattern in error
    // messages.
    template_identity: (template._identity && typeof template._identity === 'object')
      ? template._identity as Record<string, unknown>
      : undefined,
  });
}

export async function POST(req: Request) {
  let body: any;
  try { body = await req.json(); }
  catch { return NextResponse.json({ ok: false, error: 'invalid JSON' }, { status: 400 }); }

  const action = body?.action;

  if (action === 'reset') {
    const settings = loadSettings();
    settings.onboardingCompleted = false;
    saveSettings(settings);
    return NextResponse.json({ ok: true });
  }

  // 'skip' — user closes the wizard without applying. Just flip the
  // gate; do NOT run any of the apply phases (identity / template
  // _agents / _apiProfiles / connectors / pipelines / temper). The old
  // behaviour reused action=apply with payload={} which still ran the
  // template-side writes (applyConnectors → applyTemplateAgentsAndProfiles),
  // so Skip silently committed the template even though the user said no.
  if (action === 'skip') {
    const settings = loadSettings();
    settings.onboardingCompleted = true;
    saveSettings(settings);
    return NextResponse.json({ ok: true, skipped: true });
  }

  if (action !== 'apply') {
    return NextResponse.json({ ok: false, error: 'unknown action' }, { status: 400 });
  }

  const payload = (body?.payload || {}) as OnboardingPayload;
  const phases: Array<{ phase: string; ok: boolean; error?: string; detail?: any }> = [];

  // Server-side enforcement of template's identity rules. The wizard
  // also blocks at the client, but a malformed POST (curl / older client /
  // dev-tools edit) can bypass that. Reject before any phase runs — no
  // partial writes.
  try {
    const tpl = resolveTemplate(payload.sourceId, payload.deptId);
    const idCfg = (tpl && typeof tpl._identity === 'object') ? tpl._identity as any : null;
    const patternStr = idCfg?.email_pattern as string | undefined;
    const email = payload.identity?.displayEmail?.trim();
    if (patternStr && email) {
      let pat: RegExp | null = null;
      try { pat = new RegExp(patternStr); } catch { pat = null; }
      if (pat && !pat.test(email)) {
        const hint = (idCfg.email_pattern_hint as string | undefined)
          || `Must match: ${patternStr}`;
        return NextResponse.json({
          ok: false,
          error: `email "${email}" does not match the template's required pattern. ${hint}`,
        }, { status: 400 });
      }
    }
  } catch {
    // Template resolution failure → fall through; client validation
    // already covers the happy path.
  }

  try {
    const e1 = applyIdentity(payload.identity);
    phases.push({ phase: 'identity', ok: !e1, ...(e1 ? { error: e1 } : {}) });
  } catch (e) {
    phases.push({ phase: 'identity', ok: false, error: e instanceof Error ? e.message : String(e) });
  }

  try {
    const e2 = applyApiProfile(payload.apiProfile);
    phases.push({ phase: 'apiProfile', ok: !e2, ...(e2 ? { error: e2 } : {}) });
  } catch (e) {
    phases.push({ phase: 'apiProfile', ok: false, error: e instanceof Error ? e.message : String(e) });
  }

  try {
    const e2b = applyCliAgent(payload.cliAgent);
    phases.push({ phase: 'cliAgent', ok: !e2b, ...(e2b ? { error: e2b } : {}) });
  } catch (e) {
    phases.push({ phase: 'cliAgent', ok: false, error: e instanceof Error ? e.message : String(e) });
  }

  try {
    const r = await applyConnectors(payload.connectorValues, payload.selectedConnectors, payload.sourceId, payload.deptId);
    phases.push({ phase: 'connectors', ok: true, detail: r });
  } catch (e) {
    phases.push({ phase: 'connectors', ok: false, error: e instanceof Error ? e.message : String(e) });
  }

  // Memory auto-provision — runs after applyConnectors so settings.company /
  // .dept are stamped. No-ops when the template ships no _temperAdmin block
  // or the user already has a temperKey.
  try {
    const r = await applyTemperAutoProvision(payload.sourceId, payload.deptId);
    const failed = typeof r === 'object' && r !== null && 'error' in r;
    phases.push({ phase: 'temperProvision', ok: !failed, detail: r });
  } catch (e) {
    phases.push({ phase: 'temperProvision', ok: false, error: e instanceof Error ? e.message : String(e) });
  }

  try {
    const r = await applyPipelines(payload.pipelines);
    phases.push({ phase: 'pipelines', ok: r.errors.length === 0, detail: r });
  } catch (e) {
    phases.push({ phase: 'pipelines', ok: false, error: e instanceof Error ? e.message : String(e) });
  }

  try {
    const e5 = applyProjectRoots(payload.projectRoots);
    phases.push({ phase: 'projects', ok: !e5, ...(e5 ? { error: e5 } : {}) });
  } catch (e) {
    phases.push({ phase: 'projects', ok: false, error: e instanceof Error ? e.message : String(e) });
  }

  // Flip the gate regardless — partial setups still count as "user has
  // seen and acted on the wizard". They can re-run from Settings to fix.
  const settings = loadSettings();
  settings.onboardingCompleted = true;
  saveSettings(settings);

  return NextResponse.json({ ok: true, phases });
}
