'use client';

/**
 * Connectors marketplace — lives in SkillsPanel's Connectors tab.
 *
 * Lists everything in the `forge-connectors` registry merged with the
 * user's installed state. Two-pane layout (list + detail) mirroring
 * the Skills tab:
 *   - Left: registry entries with status chip + Install/Update/Remove
 *   - Right: tools, description, settings form (PAT, base URL, …)
 *
 * Talks to:
 *   GET  /api/connectors/marketplace
 *   POST /api/connectors/marketplace { action, id, refreshInstalled? }
 *   GET  /api/connectors/<id>/settings
 *   POST /api/connectors/<id>/settings
 */

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

interface MarketEntry {
  id: string;
  name: string;
  version: string;
  icon?: string;
  description?: string;
  author?: string;
  installed_version?: string;
  update_available?: boolean;
  /** Source that publishes the proposed update — may differ from
   *  source_id (the installed origin). E.g. public is installed, then
   *  the user adds an enterprise source that publishes a higher version. */
  update_source_id?: string;
  compatible: boolean;
  source: 'registry' | 'local';
  /** Which marketplace source provided the winning entry:
   *  `public` | `enterprise-<tenant>` | undefined for local-only. */
  source_id?: string;
  /** Lower-priority sources that also list this id but were outranked. */
  hidden_sources?: { source_id: string; display_name: string; version: string }[];
}

/** Compact badge describing a source. `enterprise-…` → 🔒 + name; `public` → 🌐;
 *  install-local (no registry source) → 📦 local. Tooltip carries full id. */
function SourceBadge({ entry, size = 'sm' }: { entry: Pick<MarketEntry, 'source' | 'source_id' | 'hidden_sources'>; size?: 'xs' | 'sm' }) {
  const px = size === 'xs' ? 'text-[8px] px-1 py-0.5' : 'text-[9px] px-1.5 py-0.5';
  if (entry.source === 'local' || !entry.source_id) {
    return <span className={`${px} rounded bg-[var(--accent)]/15 text-[var(--accent)]`} title="Installed locally, not from any registry">📦 local</span>;
  }
  if (entry.source_id === 'public') {
    return <span className={`${px} rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]`} title="Public marketplace">🌐 public</span>;
  }
  // enterprise-<tenant>
  const tenant = entry.source_id.replace(/^enterprise-/, '');
  const hiddenCount = entry.hidden_sources?.length || 0;
  const title = hiddenCount > 0
    ? `Enterprise source: ${tenant} (overrides ${hiddenCount} lower-priority source${hiddenCount > 1 ? 's' : ''})`
    : `Enterprise source: ${tenant}`;
  return <span className={`${px} rounded bg-amber-500/15 text-amber-400`} title={title}>🔒 {tenant}</span>;
}

interface MarketState {
  fetched_at?: string;
  base_url?: string;
  entries: MarketEntry[];
}

interface FieldSchema {
  type: string;
  label?: string;
  description?: string;
  required?: boolean;
  default?: any;
  options?: string[];
  /** For type: 'instances' — schema of each row's inner fields. */
  fields?: Record<string, FieldSchema>;
}

interface ConnectorTool {
  description?: string;
  destructive?: boolean;
  returns?: string;
  protocol?: string;
}

interface ConnectorEntryShape {
  id: string;
  tools?: Record<string, ConnectorTool>;
}

interface ConnectorDetail {
  plugin_id: string;
  name: string;
  icon: string;
  version: string;
  description?: string;
  author?: string;
  installed: boolean;
  entries: ConnectorEntryShape[];
  has_test?: boolean;
  test_description?: string;
}

interface TestResult {
  ok: boolean;
  status?: number;
  message?: string;
  error?: string;
  duration_ms?: number;
}

const SECRET_MASK = '••••••••';

// ─── Config template import ───────────────────────────────

interface TemplatePrompt {
  key: string;
  label: string;
  hint?: string;
  url?: string;
  url_label?: string;
  secret?: boolean;
  required?: boolean;
  targets: Array<{ connector: string; field_path: string }>;
}

interface TemplateModalState {
  template: any;
  pending: TemplatePrompt[];
  static_apply_targets: string[];
  missing_manifests: string[];
  values: Record<string, string>;
}

interface TemplateApplyResult {
  applied: string[];
  enabled_changed: string[];
  skipped_missing_manifest: string[];
  fields_preserved: Array<{ connector: string; field: string; reason: string }>;
  fields_left_empty: Array<{ connector: string; field: string }>;
}

export default function ConnectorsPanel() {
  const [state, setState] = useState<MarketState | null>(null);
  const [busyId, setBusyId] = useState('');
  const [syncing, setSyncing] = useState(false);
  const [error, setError] = useState('');

  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [detail, setDetail] = useState<ConnectorDetail | null>(null);
  const [schema, setSchema] = useState<Record<string, FieldSchema>>({});
  const [values, setValues] = useState<Record<string, any>>({});
  const [savedAt, setSavedAt] = useState<number | null>(null);

  const [repoUrl, setRepoUrl] = useState<string>('');
  const [repoUrlDirty, setRepoUrlDirty] = useState(false);

  const [uploading, setUploading] = useState(false);
  const [dragOver, setDragOver] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);

  // ─── Config template import ───────────────────────────────
  // The "Import Template" button accepts a JSON file that pre-fills shared
  // defaults (base URLs, etc.) and prompts the user once for each ${key}
  // placeholder. Same key shared by multiple connectors → asked once.
  const tplFileInputRef = useRef<HTMLInputElement>(null);
  const [tplAnalyzing, setTplAnalyzing] = useState(false);
  const [tplApplying, setTplApplying] = useState(false);
  const [tplModal, setTplModal] = useState<TemplateModalState | null>(null);
  const [tplResult, setTplResult] = useState<TemplateApplyResult | null>(null);

  const [testing, setTesting] = useState(false);
  const [testResult, setTestResult] = useState<TestResult | null>(null);

  // ─── Load ─────────────────────────────────────────────────
  const refresh = useCallback(async () => {
    try {
      const r = await fetch('/api/connectors/marketplace');
      setState((await r.json()) as MarketState);
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  }, []);

  const loadDetail = useCallback(async (id: string) => {
    setSavedAt(null);
    try {
      const [d, s] = await Promise.all([
        fetch(`/api/connectors?id=${id}`).then(r => r.ok ? r.json() : null),
        fetch(`/api/connectors/${id}/settings`).then(r => r.ok ? r.json() : null),
      ]);
      setDetail(d?.connector || null);
      setSchema(s?.schema || {});
      setValues(s?.settings || {});
    } catch {
      setDetail(null);
      setSchema({});
      setValues({});
    }
  }, []);

  useEffect(() => { refresh(); }, [refresh]);

  // Auto-select first entry on first load (nice default)
  useEffect(() => {
    if (!selectedId && state?.entries?.length) {
      setSelectedId(state.entries[0].id);
    }
  }, [state, selectedId]);

  useEffect(() => {
    if (selectedId) loadDetail(selectedId);
    setTestResult(null);
  }, [selectedId, loadDetail]);

  // Keep the repo-url input in sync with whatever's in settings
  useEffect(() => {
    fetch('/api/settings').then(r => r.json()).then(s => {
      if (s?.connectorsRepoUrl != null) setRepoUrl(s.connectorsRepoUrl);
    }).catch(() => {});
  }, []);

  // Forge ships a bundled connector template (templates/connector-config-template.json),
  // so the Import button is always one-click. Users can override by
  // dropping their own at <dataDir>/config-template.json — the GET
  // endpoint picks the override automatically. No mount-time probe
  // needed; we just always show the one-click button.

  // ─── Actions ──────────────────────────────────────────────
  async function sync() {
    setSyncing(true); setError('');
    try {
      // Persist URL change first if dirty
      if (repoUrlDirty) {
        await fetch('/api/settings', {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ connectorsRepoUrl: repoUrl }),
        });
        setRepoUrlDirty(false);
      }
      const r = await fetch('/api/connectors/marketplace', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'sync', refreshInstalled: true }),
      });
      const j = await r.json();
      if (!j.ok) setError(j.error || 'sync failed');
      await refresh();
      if (selectedId) await loadDetail(selectedId);
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    } finally { setSyncing(false); }
  }

  async function act(action: 'install' | 'update' | 'uninstall', id: string) {
    if (action === 'uninstall' && !confirm(`Uninstall ${id}? Your saved settings/PAT are kept and restored on re-install.`)) return;
    setBusyId(id); setError('');
    try {
      const r = await fetch('/api/connectors/marketplace', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action, id }),
      });
      const j = await r.json();
      if (!r.ok || j.ok === false) setError(j.error || `${action} failed`);
      await refresh();
      if (selectedId === id) await loadDetail(id);
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    } finally { setBusyId(''); }
  }

  async function uploadFile(file: File) {
    setUploading(true); setError('');
    try {
      const fd = new FormData();
      fd.append('file', file);
      const r = await fetch('/api/connectors/install-local', { method: 'POST', body: fd });
      const j = await r.json();
      if (!r.ok || j.ok === false) {
        setError(j.error || `install failed (HTTP ${r.status})`);
      } else {
        await refresh();
        if (j.id) setSelectedId(j.id);
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    } finally { setUploading(false); }
  }

  function onPickFile(ev: React.ChangeEvent<HTMLInputElement>) {
    const f = ev.target.files?.[0];
    ev.target.value = ''; // allow re-picking the same file
    if (f) uploadFile(f);
  }

  function onDrop(ev: React.DragEvent) {
    ev.preventDefault();
    setDragOver(false);
    const f = ev.dataTransfer.files?.[0];
    if (f) uploadFile(f);
  }

  // ─── Config template: one-click import (bundled or local override) ──
  // Always hits GET — the server picks the user override at
  // <dataDir>/config-template.json if present, else the bundled
  // templates/connector-config-template.json that ships with Forge.
  async function onClickImport() {
    if (tplAnalyzing || tplApplying) return;
    setTplAnalyzing(true); setError(''); setTplResult(null);
    try {
      const r = await fetch('/api/connectors/import-config-template');
      const j = await r.json();
      if (!r.ok || j.ok === false) {
        setError(j.error || 'template load failed');
        return;
      }
      if (!j.pending?.length) {
        await applyTemplate(j.template, {});
        return;
      }
      setTplModal({
        template: j.template,
        pending: j.pending,
        static_apply_targets: j.static_apply_targets || [],
        missing_manifests: j.missing_manifests || [],
        values: {},
      });
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    } finally { setTplAnalyzing(false); }
  }

  // ─── Config template: analyze (file pick) ─────────────────
  async function onPickTemplateFile(ev: React.ChangeEvent<HTMLInputElement>) {
    const f = ev.target.files?.[0];
    ev.target.value = '';
    if (!f) return;
    setTplAnalyzing(true); setError(''); setTplResult(null);
    try {
      const fd = new FormData();
      fd.append('file', f);
      const r = await fetch('/api/connectors/import-config-template', {
        method: 'POST', body: fd,
      });
      const j = await r.json();
      if (!r.ok || j.ok === false) {
        setError(j.error || 'template analyze failed');
        return;
      }
      // If nothing to ask, apply directly (only static defaults).
      if (!j.pending?.length) {
        await applyTemplate(j.template, {});
        return;
      }
      setTplModal({
        template: j.template,
        pending: j.pending,
        static_apply_targets: j.static_apply_targets || [],
        missing_manifests: j.missing_manifests || [],
        values: {},
      });
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    } finally { setTplAnalyzing(false); }
  }

  async function applyTemplate(template: any, values: Record<string, string>) {
    setTplApplying(true); setError('');
    try {
      const r = await fetch('/api/connectors/import-config-template', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ template, values }),
      });
      const j = await r.json();
      if (!r.ok || j.ok === false) {
        setError(j.error || 'template apply failed');
        return;
      }
      setTplModal(null);
      setTplResult(j as TemplateApplyResult);
      await refresh();
      if (selectedId) await loadDetail(selectedId);
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    } finally { setTplApplying(false); }
  }

  async function runTest() {
    if (!selectedId) return;
    setTesting(true); setTestResult(null);
    try {
      // Save first so the server-side test sees the current values
      // (most common gotcha: user pastes a new PAT, clicks Test, gets 401
      //  on the old token still on disk).
      await fetch(`/api/connectors/${selectedId}/settings`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ settings: values }),
      });
      const r = await fetch(`/api/connectors/${selectedId}/test`, { method: 'POST' });
      const j = (await r.json()) as TestResult;
      setTestResult(j);
    } catch (e) {
      setTestResult({ ok: false, error: e instanceof Error ? e.message : String(e) });
    } finally { setTesting(false); }
  }

  // Sync the Forge-stored token into the local `glab` CLI config so
  // user's shell `glab` uses the same token as Forge pipelines. Avoids
  // the "shell glab says 401 but Forge connector says OK" split-brain.
  // gitlab-only for now; other CLIs can be added in the backend route.
  const [syncingCli, setSyncingCli] = useState(false);
  const [syncCliResult, setSyncCliResult] = useState<{ ok: boolean; message?: string; error?: string; hint?: string } | null>(null);

  async function syncCli() {
    if (!selectedId) return;
    setSyncingCli(true); setSyncCliResult(null);
    try {
      // Save first so the server reads the current token (same pattern as Test).
      await fetch(`/api/connectors/${selectedId}/settings`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ settings: values }),
      });
      const r = await fetch(`/api/connectors/${selectedId}/sync-cli`, { method: 'POST' });
      const j = await r.json();
      setSyncCliResult(j);
    } catch (e) {
      setSyncCliResult({ ok: false, error: e instanceof Error ? e.message : String(e) });
    } finally { setSyncingCli(false); }
  }

  async function saveSettings() {
    if (!selectedId) return;
    try {
      const r = await fetch(`/api/connectors/${selectedId}/settings`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ settings: values }),
      });
      if (!r.ok) {
        const j = await r.json().catch(() => ({}));
        setError(j.error || 'save failed');
        return;
      }
      const j = await r.json();
      setValues(j.settings || values);
      setSavedAt(Date.now());
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    }
  }

  const entries = state?.entries || [];
  const installedCount = entries.filter(e => e.installed_version).length;
  const syncedLabel = state?.fetched_at
    ? new Date(state.fetched_at).toLocaleString()
    : 'never';

  const allTools = useMemo(() => {
    if (!detail) return [] as Array<[string, ConnectorTool]>;
    return detail.entries.flatMap(e => Object.entries(e.tools || {}));
  }, [detail]);

  return (
    <div
      className={`flex flex-1 min-h-0 flex-col relative ${dragOver ? 'ring-2 ring-[var(--accent)]/60' : ''}`}
      onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
      onDragLeave={() => setDragOver(false)}
      onDrop={onDrop}
    >
      {/* Header strip */}
      <div className="px-3 py-2 border-b border-[var(--border)] flex items-center gap-2 shrink-0">
        <span className="text-[10px] text-[var(--text-secondary)]">
          {installedCount} installed of {entries.length}
        </span>
        <span className="text-[10px] text-[var(--text-secondary)] flex-1 truncate">
          · Last synced: {syncedLabel}
        </span>
        <input
          value={repoUrl}
          onChange={e => { setRepoUrl(e.target.value); setRepoUrlDirty(true); }}
          placeholder="connectors registry URL"
          className="w-[300px] text-[10px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-0.5 text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
        />
        <input
          ref={fileInputRef}
          type="file"
          accept=".yaml,.yml,.zip"
          onChange={onPickFile}
          className="hidden"
        />
        <input
          ref={tplFileInputRef}
          type="file"
          accept=".json,application/json"
          onChange={onPickTemplateFile}
          className="hidden"
        />
        <button
          type="button"
          onClick={onClickImport}
          disabled={tplAnalyzing || tplApplying}
          title="One-click import — uses the bundled connector template (or your <dataDir>/config-template.json override). Asks once for each missing token; shared keys (e.g. gitlab_pat used by gitlab+jenkins) are asked once."
          className="text-[10px] px-2.5 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors disabled:opacity-40"
        >
          {tplAnalyzing ? 'Analyzing…' : tplApplying ? 'Applying…' : '↥ Import Template'}
        </button>
        <button
          type="button"
          onClick={() => tplFileInputRef.current?.click()}
          disabled={tplAnalyzing || tplApplying}
          title="Override: pick a different template file"
          className="text-[10px] px-1.5 py-0.5 text-[var(--text-secondary)] hover:text-[var(--accent)] disabled:opacity-40"
        >
          (or file…)
        </button>
        <button
          type="button"
          onClick={() => fileInputRef.current?.click()}
          disabled={uploading}
          title="Install a local connector — .yaml manifest or .zip bundle (drag & drop also works)"
          className="text-[10px] px-2.5 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors disabled:opacity-40"
        >
          {uploading ? 'Installing…' : '+ Upload'}
        </button>
        {/* Per-tab Sync removed — use Marketplace top → "↻ Sync all"
            which covers every type (connectors + workflows + skills)
            from every source (public + enterprise) in one trip. */}
      </div>

      {dragOver && (
        <div className="absolute inset-0 bg-[var(--accent)]/10 pointer-events-none flex items-center justify-center z-10">
          <div className="px-6 py-3 border-2 border-dashed border-[var(--accent)] bg-[var(--bg-primary)] rounded text-sm text-[var(--accent)]">
            Drop manifest.yaml or connector.zip to install
          </div>
        </div>
      )}

      {error && (
        <div className="mx-3 mt-2 px-3 py-1.5 text-[11px] text-red-400 border border-red-500/30 bg-red-500/5 rounded">
          {error}
        </div>
      )}

      <div className="flex flex-1 min-h-0">
        {/* List pane */}
        <div className="w-[280px] border-r border-[var(--border)] overflow-y-auto">
          {entries.length === 0 ? (
            <div className="p-4 text-xs text-[var(--text-secondary)] text-center">
              {state ? 'Registry is empty — click Refresh.' : 'Loading…'}
            </div>
          ) : (
            entries.map(e => {
              const installed = !!e.installed_version;
              const update = !!e.update_available;
              return (
                <button
                  key={e.id}
                  onClick={() => setSelectedId(e.id)}
                  className={`w-full text-left px-3 py-2 border-b border-[var(--border)] hover:bg-[var(--bg-tertiary)] transition-colors ${selectedId === e.id ? 'bg-[var(--bg-tertiary)]' : ''}`}
                >
                  <div className="flex items-center gap-2">
                    <span className="text-base">{e.icon || '🔌'}</span>
                    <div className="flex-1 min-w-0">
                      <div className="flex items-center gap-1.5">
                        <span className="text-[12px] font-semibold text-[var(--text-primary)] truncate">{e.name}</span>
                        {installed && !update && (
                          <span className="text-[8px] px-1 py-0.5 rounded bg-green-500/10 text-green-400">installed</span>
                        )}
                        {update && (
                          <span className="text-[8px] px-1 py-0.5 rounded bg-yellow-500/10 text-yellow-400">update</span>
                        )}
                        <SourceBadge entry={e} size="xs" />
                      </div>
                      <div className="flex items-center gap-1.5 mt-0.5">
                        <span className="text-[9px] text-[var(--text-secondary)]">
                          v{e.version}
                          {installed && update && (
                            <span className="text-yellow-400"> ← v{e.installed_version}</span>
                          )}
                        </span>
                      </div>
                    </div>
                  </div>
                  {e.description && (
                    <p className="text-[10px] text-[var(--text-secondary)] mt-1 line-clamp-2">{e.description}</p>
                  )}
                </button>
              );
            })
          )}
        </div>

        {/* Detail pane */}
        <div className="flex-1 overflow-y-auto min-h-0">
          {!selectedId ? (
            <div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">
              Select a connector
            </div>
          ) : (() => {
            const me = entries.find(e => e.id === selectedId);
            if (!me) return null;
            const installed = !!me.installed_version;
            const update = !!me.update_available;
            return (
              <div className="p-4 space-y-4">
                {/* Header */}
                <div className="flex items-start gap-3">
                  <span className="text-2xl">{me.icon || '🔌'}</span>
                  <div className="flex-1 min-w-0">
                    <div className="flex items-center gap-2 flex-wrap">
                      <h2 className="text-base font-semibold text-[var(--text-primary)]">{me.name}</h2>
                      <span className="text-[10px] text-[var(--text-secondary)]">v{me.version}</span>
                      {installed && !update && (
                        <span className="text-[9px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400">installed</span>
                      )}
                      {update && (
                        <span className="text-[9px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400">
                          update available · v{me.installed_version} → v{me.version}
                        </span>
                      )}
                      <SourceBadge entry={me} />
                      {installed && me.update_source_id && me.update_source_id !== me.source_id && (
                        <span
                          className="text-[9px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400"
                          title={`Installed manifest comes from ${me.source_id}, but ${me.update_source_id} also publishes this connector at a higher priority. Reinstall to switch.`}
                        >
                          ↻ also in {me.update_source_id === 'public'
                            ? 'public'
                            : me.update_source_id.replace(/^enterprise-/, '')}
                        </span>
                      )}
                    </div>
                    {me.description && (
                      <p className="text-[11px] text-[var(--text-secondary)] mt-1 whitespace-pre-line">{me.description}</p>
                    )}
                  </div>
                  <div className="flex items-center gap-1.5 shrink-0">
                    {!installed && (
                      <button
                        onClick={() => act('install', me.id)}
                        disabled={busyId === me.id || !me.compatible}
                        className="text-[10px] px-2.5 py-1 rounded border border-[var(--accent)] text-[var(--accent)] hover:bg-[var(--accent)] hover:text-white transition-colors disabled:opacity-40"
                      >
                        {busyId === me.id ? '…' : 'Install'}
                      </button>
                    )}
                    {installed && update && (
                      <button
                        onClick={() => act('update', me.id)}
                        disabled={busyId === me.id}
                        className="text-[10px] px-2.5 py-1 rounded border border-yellow-500/60 text-yellow-400 hover:bg-yellow-500 hover:text-white transition-colors disabled:opacity-40"
                      >
                        {busyId === me.id ? '…' : 'Update'}
                      </button>
                    )}
                    {installed && !update && (
                      <button
                        onClick={() => act('update', me.id)}
                        disabled={busyId === me.id}
                        title="Re-pull this connector's manifest from the highest-priority source (force, even if version matches). Use this when you bumped the manifest version but the marketplace top-level Sync doesn't seem to have picked it up."
                        className="text-[10px] px-2 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--text-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-40"
                      >
                        {busyId === me.id ? '…' : '↻'}
                      </button>
                    )}
                    {installed && (
                      <button
                        onClick={() => act('uninstall', me.id)}
                        disabled={busyId === me.id}
                        className="text-[10px] px-2.5 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
                      >
                        Remove
                      </button>
                    )}
                  </div>
                </div>

                {/* Sources — surface enterprise winner + outranked rows so
                    the user can see "this mantis comes from fortinet, public
                    has one too but it's hidden". Hide entirely for the boring
                    case (public winner with no overlap). */}
                {(() => {
                  const isEnterpriseWinner = me.source_id && me.source_id !== 'public' && me.source !== 'local';
                  const hasHidden = !!me.hidden_sources?.length;
                  if (!isEnterpriseWinner && !hasHidden) return null;
                  return (
                    <div>
                      <h3 className="text-[11px] font-semibold text-[var(--text-primary)] mb-1.5">
                        Sources <span className="text-[9px] text-[var(--text-secondary)] font-normal">· higher priority first</span>
                      </h3>
                      <div className="rounded border border-[var(--border)] divide-y divide-[var(--border)] bg-[var(--bg-tertiary)]">
                        <div className="px-2.5 py-1.5 flex items-center gap-2">
                          <SourceBadge entry={me} size="xs" />
                          <span className="text-[10px] font-mono text-[var(--text-primary)]">v{me.version}</span>
                          <span className="text-[9px] text-green-400 ml-auto">in use</span>
                        </div>
                        {me.hidden_sources?.map((hs) => (
                          <div key={hs.source_id} className="px-2.5 py-1.5 flex items-center gap-2 opacity-60">
                            <SourceBadge
                              entry={{ source: 'registry', source_id: hs.source_id }}
                              size="xs"
                            />
                            <span className="text-[10px] font-mono text-[var(--text-secondary)]">v{hs.version}</span>
                            <span className="text-[9px] text-[var(--text-secondary)] ml-auto">hidden</span>
                          </div>
                        ))}
                      </div>
                    </div>
                  );
                })()}

                {/* Tools — only when manifest is on disk */}
                {installed && detail && (
                  <div>
                    <div className="flex items-center justify-between mb-1.5">
                      <h3 className="text-[11px] font-semibold text-[var(--text-primary)]">
                        Tools ({allTools.length})
                      </h3>
                      <span className="text-[9px] text-[var(--text-secondary)] italic">runtime: browser / http / shell</span>
                    </div>
                    <div className="grid gap-1.5">
                      {allTools.map(([name, tool]) => (
                        <div key={name} className="px-2.5 py-1.5 rounded bg-[var(--bg-tertiary)]">
                          <div className="flex items-center gap-2 mb-0.5">
                            <span className="text-[10px] font-mono font-semibold text-[var(--accent)]">{name}</span>
                            {tool.protocol && tool.protocol !== 'browser' && (
                              <span className="text-[8px] px-1 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">{tool.protocol}</span>
                            )}
                            {tool.destructive && (
                              <span className="text-[8px] px-1 py-0.5 rounded bg-red-500/10 text-red-400">destructive</span>
                            )}
                          </div>
                          {tool.description && (
                            <p className="text-[10px] text-[var(--text-secondary)] whitespace-pre-line">{tool.description.trim()}</p>
                          )}
                          {tool.returns && (
                            <p className="text-[9px] text-[var(--text-secondary)] mt-1 font-mono">→ {tool.returns}</p>
                          )}
                        </div>
                      ))}
                    </div>
                  </div>
                )}

                {/* Settings — only when manifest is on disk */}
                {installed && Object.keys(schema).length > 0 && (
                  <div>
                    <div className="flex items-center justify-between mb-1.5">
                      <h3 className="text-[11px] font-semibold text-[var(--text-primary)]">Settings</h3>
                      {savedAt && (
                        <span className="text-[9px] text-green-400">saved {new Date(savedAt).toLocaleTimeString()}</span>
                      )}
                    </div>
                    <div className="space-y-2">
                      {Object.entries(schema).map(([key, sc]) => (
                        <div key={key}>
                          <label className="text-[10px] text-[var(--text-secondary)] block mb-0.5">
                            {sc.label || key} {sc.required && <span className="text-red-400">*</span>}
                          </label>
                          {sc.type === 'instances' ? (
                            <InstancesField
                              schema={sc}
                              rawValue={values[key]}
                              onChange={(v) => setValues({ ...values, [key]: v })}
                            />
                          ) : sc.type === 'boolean' ? (
                            <input
                              type="checkbox"
                              checked={values[key] === true || values[key] === 'true'}
                              onChange={e => setValues({ ...values, [key]: e.target.checked })}
                              className="accent-[var(--accent)]"
                            />
                          ) : sc.type === 'select' ? (
                            <select
                              value={values[key] ?? sc.default ?? ''}
                              onChange={e => setValues({ ...values, [key]: e.target.value })}
                              className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)]"
                            >
                              <option value="">Select…</option>
                              {(sc.options || []).map(o => <option key={o} value={o}>{o}</option>)}
                            </select>
                          ) : (
                            <input
                              type={sc.type === 'secret' || sc.type === 'password' ? 'password' : sc.type === 'number' ? 'number' : 'text'}
                              value={values[key] ?? sc.default ?? ''}
                              onChange={e => setValues({ ...values, [key]: e.target.value })}
                              placeholder={
                                (sc.type === 'secret' || sc.type === 'password') && values[key] === SECRET_MASK
                                  ? 'click to replace'
                                  : sc.description || ''
                              }
                              className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[11px] text-[var(--text-primary)] font-mono"
                            />
                          )}
                          {sc.description && (
                            <p className="text-[9px] text-[var(--text-secondary)] mt-0.5">{sc.description}</p>
                          )}
                        </div>
                      ))}
                      <div className="flex items-center gap-2 flex-wrap">
                        <button
                          onClick={saveSettings}
                          className="text-[10px] px-2.5 py-1 rounded border border-[var(--accent)] text-[var(--accent)] hover:bg-[var(--accent)] hover:text-white transition-colors"
                        >
                          Save settings
                        </button>
                        {detail?.has_test && (
                          <button
                            onClick={runTest}
                            disabled={testing}
                            title={detail.test_description || 'Verify credentials reach the service'}
                            className="text-[10px] px-2.5 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors disabled:opacity-40"
                          >
                            {testing ? 'Testing…' : 'Test'}
                          </button>
                        )}
                        {/* Sync the Forge token into the local `glab` CLI config so
                            user's shell `glab` matches what Forge uses for
                            pipelines. Only relevant for gitlab right now. */}
                        {selectedId === 'gitlab' && (
                          <button
                            onClick={syncCli}
                            disabled={syncingCli}
                            title="Write current Forge token into ~/.config/glab-cli/config.yml so shell `glab` uses the same token. Avoids 'shell 401 but Forge OK' split."
                            className="text-[10px] px-2.5 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors disabled:opacity-40"
                          >
                            {syncingCli ? 'Syncing…' : 'Sync to glab CLI'}
                          </button>
                        )}
                        {testResult && (
                          <span
                            className={`text-[10px] ${
                              testResult.ok ? 'text-green-400' : 'text-red-400'
                            }`}
                          >
                            {testResult.ok
                              ? `✓ ${testResult.message}${testResult.duration_ms != null ? ` · ${testResult.duration_ms}ms` : ''}`
                              : `✗ ${testResult.error || `HTTP ${testResult.status}`}`}
                          </span>
                        )}
                        {syncCliResult && (
                          <span
                            className={`text-[10px] ${syncCliResult.ok ? 'text-green-400' : 'text-red-400'}`}
                            title={syncCliResult.hint}
                          >
                            {syncCliResult.ok ? `✓ ${syncCliResult.message}` : `✗ ${syncCliResult.error}`}
                          </span>
                        )}
                      </div>
                    </div>
                  </div>
                )}

                {/* Not-installed hint */}
                {!installed && (
                  <p className="text-[11px] text-[var(--text-secondary)] italic">
                    Install this connector to see its tools and configure its settings (PAT, host URL, etc.).
                  </p>
                )}
              </div>
            );
          })()}
        </div>
      </div>

      {/* ─── Template Import: prompt modal ─────────────────── */}
      {tplModal && (
        <TemplateImportModal
          state={tplModal}
          onChange={(values) => setTplModal({ ...tplModal, values })}
          onCancel={() => setTplModal(null)}
          onSubmit={(values) => applyTemplate(tplModal.template, values)}
          submitting={tplApplying}
        />
      )}

      {/* ─── Template Import: result toast ─────────────────── */}
      {tplResult && (
        <TemplateImportResultModal
          result={tplResult}
          onClose={() => setTplResult(null)}
        />
      )}
    </div>
  );
}

// ─── Template Import: prompt modal ──────────────────────────

function TemplateImportModal({
  state, onChange, onCancel, onSubmit, submitting,
}: {
  state: TemplateModalState;
  onChange: (values: Record<string, string>) => void;
  onCancel: () => void;
  onSubmit: (values: Record<string, string>) => void;
  submitting: boolean;
}) {
  const required = state.pending.filter(p => p.required);
  const missingRequired = required.filter(p => !(state.values[p.key] || '').trim());

  return (
    <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-6">
      <div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-md shadow-xl max-w-2xl w-full max-h-[85vh] flex flex-col">
        <div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
          <h3 className="text-sm font-medium text-[var(--text-primary)]">Import Connector Config Template</h3>
          <p className="text-[11px] text-[var(--text-secondary)] mt-1">
            Static defaults will be applied to <strong>{state.static_apply_targets.length}</strong> connector{state.static_apply_targets.length === 1 ? '' : 's'} ({state.static_apply_targets.join(', ')}).
            {state.missing_manifests.length > 0 && (
              <> Skipped (not installed): <span className="text-amber-500">{state.missing_manifests.join(', ')}</span>.</>
            )}
            <br />
            Fill in the fields below — values shared across connectors are asked once. Existing non-empty fields are preserved.
          </p>
        </div>
        <div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
          {state.pending.map(p => {
            const v = state.values[p.key] ?? '';
            return (
              <div key={p.key} className="space-y-1">
                <label className="text-[11px] font-medium text-[var(--text-primary)] flex items-center gap-1.5 flex-wrap">
                  {p.label}
                  {p.required ? <span className="text-red-500">*</span> : <span className="text-[9px] text-[var(--text-secondary)]">(optional)</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-[10px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>
                )}
                <input
                  type={p.secret ? 'password' : 'text'}
                  value={v}
                  onChange={(e) => onChange({ ...state.values, [p.key]: e.target.value })}
                  placeholder={p.required ? 'required' : 'leave blank to skip'}
                  className="w-full text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
                />
                <p className="text-[9px] text-[var(--text-secondary)]">
                  → {p.targets.map(t => `${t.connector}${t.field_path ? '.' + t.field_path : ''}`).join(', ')}
                </p>
              </div>
            );
          })}
        </div>
        <div className="px-4 py-2 border-t border-[var(--border)] shrink-0 flex items-center justify-end gap-2">
          {missingRequired.length > 0 && (
            <span className="text-[10px] text-amber-500 mr-auto">
              {missingRequired.length} required field{missingRequired.length === 1 ? '' : 's'} empty
            </span>
          )}
          <button
            type="button"
            onClick={onCancel}
            disabled={submitting}
            className="text-[10px] px-2.5 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--text-primary)] disabled:opacity-40"
          >
            Cancel
          </button>
          <button
            type="button"
            onClick={() => onSubmit(state.values)}
            disabled={submitting || missingRequired.length > 0}
            className="text-[10px] px-2.5 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-40"
          >
            {submitting ? 'Applying…' : 'Apply'}
          </button>
        </div>
      </div>
    </div>
  );
}

function TemplateImportResultModal({
  result, onClose,
}: { result: TemplateApplyResult; onClose: () => void; }) {
  return (
    <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-6">
      <div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-md shadow-xl max-w-xl w-full max-h-[80vh] flex flex-col">
        <div className="px-4 py-3 border-b border-[var(--border)] shrink-0 flex items-center">
          <h3 className="text-sm font-medium text-[var(--text-primary)]">Import Complete</h3>
        </div>
        <div className="flex-1 overflow-y-auto px-4 py-3 space-y-3 text-[11px] text-[var(--text-primary)]">
          <div>
            <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Applied ({result.applied.length})</div>
            <div className="text-emerald-500">{result.applied.join(', ') || '—'}</div>
          </div>
          {result.fields_preserved.length > 0 && (
            <div>
              <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Preserved (existing values not overwritten)</div>
              <ul className="text-[var(--text-secondary)] font-mono text-[10px] space-y-0.5">
                {result.fields_preserved.map((f, i) => (
                  <li key={i}>{f.connector}.{f.field}</li>
                ))}
              </ul>
            </div>
          )}
          {result.fields_left_empty.length > 0 && (
            <div>
              <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Left empty (optional fields skipped)</div>
              <ul className="text-amber-500 font-mono text-[10px] space-y-0.5">
                {result.fields_left_empty.map((f, i) => (
                  <li key={i}>{f.connector}.{f.field}</li>
                ))}
              </ul>
            </div>
          )}
          {result.skipped_missing_manifest.length > 0 && (
            <div>
              <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Skipped (manifest not installed)</div>
              <div className="text-amber-500">{result.skipped_missing_manifest.join(', ')}</div>
              <p className="text-[10px] text-[var(--text-secondary)] mt-1">
                Sync the marketplace first (Refresh button), then re-import the template.
              </p>
            </div>
          )}
        </div>
        <div className="px-4 py-2 border-t border-[var(--border)] shrink-0 flex justify-end">
          <button
            type="button"
            onClick={onClose}
            className="text-[10px] px-2.5 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
          >
            OK
          </button>
        </div>
      </div>
    </div>
  );
}

/**
 * Generic renderer for `type: instances` — a list of named records
 * configured per-connector (Jenkins instances, GitLab tenants, etc.).
 * Each row collapses into the connector's declared sub-fields. Value
 * is round-tripped as a JSON-stringified array so the existing
 * connector-configs.json shape (the textarea-saved string) keeps
 * working unchanged.
 */
function InstancesField({
  schema,
  rawValue,
  onChange,
}: {
  schema: FieldSchema;
  rawValue: any;
  onChange: (next: string) => void;
}) {
  const subFields = schema.fields || {};
  const subKeys = Object.keys(subFields);

  // Parse incoming value: accept stored JSON string, in-memory array,
  // null, or anything malformed (treat as empty).
  const rows: Record<string, any>[] = (() => {
    let v: any = rawValue;
    if (v == null || v === '') return [];
    if (typeof v === 'string') {
      try { v = JSON.parse(v); } catch { return []; }
    }
    return Array.isArray(v) ? v : [];
  })();

  const commit = (next: Record<string, any>[]) => onChange(JSON.stringify(next));

  const addRow = () => {
    const blank: Record<string, any> = {};
    for (const k of subKeys) blank[k] = subFields[k]?.default ?? '';
    commit([...rows, blank]);
  };
  const removeRow = (i: number) => commit(rows.filter((_, idx) => idx !== i));
  const updateRow = (i: number, key: string, val: any) => {
    const next = rows.map((r, idx) => (idx === i ? { ...r, [key]: val } : r));
    commit(next);
  };

  return (
    <div className="space-y-1.5">
      {rows.length === 0 && (
        <div className="text-[10px] text-[var(--text-secondary)] italic py-1">
          No entries yet — click “+ Add” to create one.
        </div>
      )}
      {rows.map((row, i) => {
        const rowLabel =
          (typeof row.name === 'string' && row.name.trim()) || `(unnamed #${i + 1})`;
        return (
          <div
            key={i}
            className="border border-[var(--border)] rounded p-2 bg-[var(--bg-secondary)]"
          >
            <div className="flex items-center justify-between mb-1.5">
              <span className="text-[10px] font-semibold text-[var(--text-primary)]">
                {rowLabel}
              </span>
              <button
                type="button"
                onClick={() => removeRow(i)}
                title="Remove this entry"
                className="text-[10px] text-red-400 hover:text-red-300"
              >
                ✕
              </button>
            </div>
            <div className="space-y-1.5">
              {subKeys.map((k) => {
                const f = subFields[k];
                // Nested instances — render the same component recursively.
                // Lets a connector model "rows of rows" (e.g. Jenkins
                // instances each with a list of inject_params key/value
                // pairs).
                if (f.type === 'instances') {
                  return (
                    <div key={k}>
                      <label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">
                        {f.label || k} {f.required && <span className="text-red-400">*</span>}
                      </label>
                      <InstancesField
                        schema={f}
                        rawValue={row[k]}
                        onChange={(next) => updateRow(i, k, next)}
                      />
                      {f.description && (
                        <p className="text-[9px] text-[var(--text-secondary)] mt-0.5">{f.description}</p>
                      )}
                    </div>
                  );
                }
                const inputType =
                  f.type === 'secret' || f.type === 'password'
                    ? 'password'
                    : f.type === 'number'
                      ? 'number'
                      : 'text';
                return (
                  <div key={k}>
                    <label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">
                      {f.label || k} {f.required && <span className="text-red-400">*</span>}
                    </label>
                    <input
                      type={inputType}
                      value={row[k] ?? ''}
                      onChange={(e) => updateRow(i, k, e.target.value)}
                      placeholder={f.description || ''}
                      className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[10px] text-[var(--text-primary)] font-mono"
                    />
                  </div>
                );
              })}
            </div>
          </div>
        );
      })}
      <button
        type="button"
        onClick={addRow}
        className="text-[10px] px-2 py-1 rounded border border-dashed border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors w-full"
      >
        + Add {schema.label || 'instance'}
      </button>
    </div>
  );
}
