<template>
  <dialog :open="show" class="modal modal-bottom sm:modal-middle">
    <div
      class="modal-box p-0 overflow-hidden flex flex-col"
      :style="modalSizing"
    >
      <!-- Header: title + close -->
      <div class="flex items-start justify-between px-6 pt-5 pb-3">
        <div>
          <h3 class="font-bold text-lg">MCPProxy setup</h3>
          <p class="text-xs opacity-60 mt-1">
            <template v-if="onboarding.incompleteTabCount > 0">
              {{ onboarding.incompleteTabCount }}
              {{ onboarding.incompleteTabCount === 1 ? 'step' : 'steps' }}
              still to do
            </template>
            <template v-else>You're all set up.</template>
          </p>
        </div>
        <button class="btn btn-ghost btn-sm btn-square" @click="dismiss" aria-label="Close">✕</button>
      </div>

      <!-- Tabs -->
      <div role="tablist" class="tabs tabs-border px-6 shrink-0">
        <a
          v-for="tab in tabs"
          :key="tab.id"
          role="tab"
          class="tab gap-2"
          :class="{ 'tab-active text-primary': activeTab === tab.id }"
          :data-test="`tab-${tab.id}`"
          @click="activeTab = tab.id"
        >
          <span
            class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[11px] font-semibold"
            :class="tab.complete ? 'bg-success text-success-content' : 'bg-base-300 text-base-content/60'"
          >
            <span v-if="tab.complete">✓</span>
            <span v-else>{{ tab.idx }}</span>
          </span>
          <span>{{ tab.label }}</span>
        </a>
      </div>

      <!-- Body (scrollable) -->
      <div class="px-6 py-4 overflow-y-auto flex-1">
        <!-- ============================ -->
        <!-- Tab: Clients -->
        <!-- ============================ -->
        <section v-if="activeTab === 'clients'" data-test="panel-clients">
          <p class="text-sm opacity-70 mb-4">
            Pick at least one AI tool. MCPProxy registers itself in that tool's config so the assistant can talk to mcpproxy. You'll see the exact change before anything is written, and a timestamped backup is created first.
          </p>

          <div v-if="loadingClients" class="flex justify-center py-6">
            <span class="loading loading-spinner loading-md"></span>
          </div>
          <div v-else-if="clientsError" class="alert alert-error mb-2">
            <span class="text-sm">{{ clientsError }}</span>
          </div>

          <template v-else>
            <!-- Detected clients -->
            <div v-if="detectedClients.length > 0" class="space-y-2 mb-4">
              <div class="text-[11px] font-semibold uppercase tracking-wider opacity-50">Detected on this machine</div>
              <ClientRow
                v-for="c in detectedClients"
                :key="c.id"
                :client="c"
                :busy="busyClients[c.id]"
                :backup-path="connectBackups[c.id]"
                :copied-backup="copiedBackupClient === c.id"
                :preview="previews[c.id]"
                :preview-busy="previewBusy[c.id]"
                :preview-error="previewErrors[c.id]"
                :undo-preview="undoPreviews[c.id]"
                :undo-open="undoOpen[c.id]"
                :undo-busy="undoBusy[c.id]"
                @copy-backup="copyBackupPath"
                @connect="startConnect"
                @confirm="confirmConnect"
                @cancel="cancelPreview"
                @undo="startUndo"
                @confirm-undo="confirmUndo"
                @cancel-undo="cancelUndo"
              />
            </div>

            <!-- Pinned trio (only the ones not already in detected) -->
            <div v-if="pinnedClients.length > 0" class="space-y-2 mb-4">
              <div class="text-[11px] font-semibold uppercase tracking-wider opacity-50">Most popular</div>
              <ClientRow
                v-for="c in pinnedClients"
                :key="c.id"
                :client="c"
                :busy="busyClients[c.id]"
                :backup-path="connectBackups[c.id]"
                :copied-backup="copiedBackupClient === c.id"
                :preview="previews[c.id]"
                :preview-busy="previewBusy[c.id]"
                :preview-error="previewErrors[c.id]"
                :undo-preview="undoPreviews[c.id]"
                :undo-open="undoOpen[c.id]"
                :undo-busy="undoBusy[c.id]"
                @copy-backup="copyBackupPath"
                @connect="startConnect"
                @confirm="confirmConnect"
                @cancel="cancelPreview"
                @undo="startUndo"
                @confirm-undo="confirmUndo"
                @cancel-undo="cancelUndo"
              />
            </div>

            <!-- Collapsible: more clients -->
            <details v-if="moreClients.length > 0" class="group mb-2">
              <summary class="cursor-pointer text-sm opacity-70 hover:opacity-100 select-none flex items-center gap-1 py-1">
                <span class="transition-transform group-open:rotate-90">▸</span>
                Show {{ moreClients.length }} more {{ moreClients.length === 1 ? 'client' : 'clients' }}
              </summary>
              <div class="space-y-2 mt-2">
                <ClientRow
                  v-for="c in moreClients"
                  :key="c.id"
                  :client="c"
                  :busy="busyClients[c.id]"
                  :backup-path="connectBackups[c.id]"
                  :copied-backup="copiedBackupClient === c.id"
                  :preview="previews[c.id]"
                  :preview-busy="previewBusy[c.id]"
                  :preview-error="previewErrors[c.id]"
                  :undo-preview="undoPreviews[c.id]"
                  :undo-open="undoOpen[c.id]"
                  :undo-busy="undoBusy[c.id]"
                  @copy-backup="copyBackupPath"
                  @connect="startConnect"
                  @confirm="confirmConnect"
                  @cancel="cancelPreview"
                  @undo="startUndo"
                  @confirm-undo="confirmUndo"
                  @cancel-undo="cancelUndo"
                />
              </div>
            </details>

            <div v-if="connectMessage" class="mt-3">
              <div class="alert alert-sm" :class="connectMessageOk ? 'alert-success' : 'alert-error'">
                <span class="text-sm">{{ connectMessage }}</span>
              </div>
            </div>
          </template>

          <!-- Inline security expander (Spec 046 v2 FR-V06) -->
          <details class="mt-6 border-t border-base-300 pt-4">
            <summary class="cursor-pointer text-sm font-medium opacity-80 hover:opacity-100 select-none flex items-center gap-1">
              <span class="transition-transform group-open:rotate-90">▸</span>
              Show security settings
            </summary>
            <div class="mt-3 space-y-3 text-sm">
              <div class="flex items-start gap-3">
                <div class="flex-1 min-w-0">
                  <div class="font-medium">Bind interface</div>
                  <div class="text-xs opacity-60 mt-0.5">
                    mcpproxy is listening on <code class="font-mono text-[11px]">{{ listenAddr || 'localhost' }}</code>.
                    To expose it on the LAN, edit <span class="opacity-80">listen</span> in
                    <router-link to="/settings" class="link link-primary">Settings → Configuration</router-link>.
                  </div>
                </div>
              </div>
              <div class="flex items-start gap-3">
                <input
                  type="checkbox"
                  class="checkbox checkbox-sm mt-0.5"
                  :checked="requireMcpAuth"
                  :disabled="securityBusy"
                  @change="onToggleRequireAuth(($event.target as HTMLInputElement).checked)"
                  data-test="toggle-require-mcp-auth"
                />
                <div class="flex-1 min-w-0">
                  <div class="font-medium">Require API key on /mcp</div>
                  <div class="text-xs opacity-60 mt-0.5">
                    On by default for LAN-bound mcpproxy. Off-by-default keeps localhost-only setup frictionless.
                  </div>
                </div>
              </div>
            </div>
          </details>
        </section>

        <!-- ============================ -->
        <!-- Tab: Servers -->
        <!-- ============================ -->
        <section v-else-if="activeTab === 'servers'" data-test="panel-servers">
          <p class="text-sm opacity-70 mb-4">
            Pick which servers from your existing AI clients to import. Same name in multiple sources? We auto-rename collisions like
            <code class="font-mono text-[11px] bg-base-200 px-1 rounded">mcpproxy_claude_code</code> so each entry stays distinct.
          </p>

          <!-- Detected import sources (Spec 046 v2 — sectioned checkbox layout) -->
          <div v-if="loadingImportSources" class="flex justify-center py-4">
            <span class="loading loading-spinner loading-md"></span>
          </div>
          <div v-else-if="importSourcesWithServers.length === 0" class="text-sm opacity-60 py-4 text-center">
            No client configs with importable servers detected on this machine.
          </div>
          <div v-else class="border border-base-300 rounded-lg overflow-hidden mb-5">
            <div
              v-for="(src, idx) in importSourcesWithServers"
              :key="src.path"
              :data-test="`import-section-${src.format}`"
              :class="idx > 0 ? 'border-t border-base-300' : ''"
            >
              <!-- Section header: client name + path + select-all checkbox -->
              <label class="flex items-start gap-3 px-3 py-2 bg-base-200/50 cursor-pointer">
                <input
                  type="checkbox"
                  class="checkbox checkbox-sm mt-0.5"
                  :checked="isAllSelected(src)"
                  :indeterminate.prop="isIndeterminate(src)"
                  :data-test="`select-all-${src.format}`"
                  @change="toggleAllInSource(src, ($event.target as HTMLInputElement).checked)"
                />
                <div class="min-w-0 flex-1">
                  <div class="font-medium text-sm flex items-center gap-2">
                    <span>{{ src.name }}</span>
                    <span class="text-[11px] opacity-50">— {{ src.serverCount }} server{{ src.serverCount === 1 ? '' : 's' }}</span>
                  </div>
                  <div class="text-[11px] opacity-50 truncate font-mono" :title="src.path">{{ src.path }}</div>
                </div>
              </label>
              <!-- Server rows (indented + with vertical guide so the visual
                   hierarchy 'this server belongs to that client' is obvious) -->
              <ul class="divide-y divide-base-300">
                <li
                  v-for="name in src.serverNames"
                  :key="name"
                  class="flex items-center gap-3 pl-10 pr-3 py-2 relative hover:bg-base-200/40"
                >
                  <!-- Vertical guide -->
                  <span class="absolute left-5 top-0 bottom-0 w-px bg-base-300" aria-hidden="true"></span>
                  <label class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
                    <input
                      type="checkbox"
                      class="checkbox checkbox-sm"
                      :checked="isSelected(src.path, name)"
                      :data-test="`server-checkbox-${src.format}-${name}`"
                      @change="toggleServer(src.path, name, ($event.target as HTMLInputElement).checked)"
                    />
                    <span class="text-sm truncate">{{ name }}</span>
                  </label>
                  <span
                    v-if="conflictTarget(src, name)"
                    class="badge badge-warning badge-sm gap-1 shrink-0 font-normal"
                    :title="`This name conflicts across sources — will be imported as ${conflictTarget(src, name)}`"
                  >
                    →
                    <code class="font-mono text-[11px]">{{ conflictTarget(src, name) }}</code>
                  </span>
                </li>
              </ul>
            </div>
          </div>

          <!-- Selection error (after import attempt) -->
          <div v-if="selectionImportMessage" class="alert mb-4" :class="selectionImportOk ? 'alert-success' : 'alert-error'">
            <span class="text-sm">{{ selectionImportMessage }}</span>
          </div>

          <details class="border border-base-300 rounded-lg p-3 text-sm" data-test="manual-add-details">
            <summary class="cursor-pointer font-medium flex items-center gap-2 select-none">
              <span class="transition-transform group-open:rotate-90">▸</span>
              Add a single server manually instead
            </summary>
            <div class="mt-3">
              <button
                class="btn btn-primary btn-sm w-full"
                @click="openAddServer"
                data-test="add-server-button"
              >
                Open the add-server form
              </button>
              <p v-if="serverAddedJustNow" class="text-xs text-success mt-2">
                ✓ Server added — it's currently in quarantine. Review it on the Servers page after this wizard.
              </p>
              <p v-else-if="onboarding.hasConfiguredServer" class="text-xs opacity-60 mt-2">
                {{ serverCountLabel }} configured.
              </p>
            </div>
          </details>
        </section>

        <!-- ============================ -->
        <!-- Tab: Verify -->
        <!-- ============================ -->
        <section v-else-if="activeTab === 'verify'" data-test="panel-verify">
          <template v-if="onboarding.firstMCPClientEver">
            <div class="flex flex-col items-center gap-2 py-6 text-center">
              <div class="text-4xl">✅</div>
              <div class="font-semibold text-lg">Round-trip verified</div>
              <div class="text-sm opacity-70 max-w-md">
                We've seen at least one MCP request from your AI client(s). mcpproxy is wired up correctly.
              </div>
              <div v-if="onboarding.mcpClientsSeenEver.length > 0" class="text-xs opacity-60 mt-2">
                Recognized: <span class="font-medium">{{ onboarding.mcpClientsSeenEver.join(', ') }}</span>
              </div>
            </div>
          </template>
          <template v-else>
            <div class="flex flex-col items-center gap-3 py-6 text-center">
              <div class="text-4xl opacity-50">📡</div>
              <div class="font-semibold text-lg">Waiting for your first request</div>
              <div class="text-sm opacity-70 max-w-md">
                Open your AI agent and ask it to call <code class="font-mono text-[12px]">retrieve_tools</code> through mcpproxy. We'll detect the round-trip live.
              </div>
              <div class="flex items-center gap-2 mt-2 text-xs opacity-60">
                <span class="loading loading-dots loading-sm"></span>
                <span>Listening…</span>
              </div>
            </div>
          </template>

          <!-- Quick prompt suggestions: each one exercises a different built-
               in mcpproxy tool so the user can see the proxy's value surface
               immediately. -->
          <div class="mt-4 border-t border-base-300 pt-4">
            <div class="text-[11px] font-semibold uppercase tracking-wider opacity-50 mb-2">Try one of these prompts</div>
            <ul class="space-y-1.5" data-test="verify-sample-prompts">
              <li class="bg-base-200 rounded-lg p-2.5 text-sm font-mono">
                "Search for MCP filesystem tools."
                <span class="text-[11px] opacity-50 ml-2 not-italic font-sans">→ retrieve_tools</span>
              </li>
              <li class="bg-base-200 rounded-lg p-2.5 text-sm font-mono">
                "List my upstream MCP servers and their connection status."
                <span class="text-[11px] opacity-50 ml-2 not-italic font-sans">→ upstream_servers</span>
              </li>
              <li class="bg-base-200 rounded-lg p-2.5 text-sm font-mono">
                "Show me tools pending quarantine approval in mcpproxy."
                <span class="text-[11px] opacity-50 ml-2 not-italic font-sans">→ quarantine_security</span>
              </li>
            </ul>
          </div>

          <!-- Recent activity preview — every MCP request is observable here
               and on the Activity Log page (Spec 046 v2) -->
          <div class="mt-4 border-t border-base-300 pt-4" data-test="verify-activity-section">
            <div class="flex items-center justify-between mb-2">
              <div class="text-[11px] font-semibold uppercase tracking-wider opacity-50">Recent activity</div>
              <router-link to="/activity" class="text-[11px] link link-primary opacity-80 hover:opacity-100" data-test="link-activity-log">
                View all in Activity Log →
              </router-link>
            </div>
            <div v-if="loadingActivity" class="flex justify-center py-3">
              <span class="loading loading-spinner loading-sm"></span>
            </div>
            <div
              v-else-if="recentActivity.length === 0"
              class="bg-base-200 rounded-lg p-3 text-sm opacity-70 text-center"
              data-test="verify-activity-empty"
            >
              Once your AI starts calling tools, every request shows up here — and in the
              <router-link to="/activity" class="link link-primary">Activity Log</router-link>.
            </div>
            <ul v-else class="space-y-1.5" data-test="verify-activity-list">
              <li
                v-for="rec in recentActivity"
                :key="rec.id"
                class="flex items-center gap-3 px-3 py-2 rounded-lg bg-base-200/60 text-sm"
              >
                <span
                  class="badge badge-xs shrink-0"
                  :class="rec.status === 'success' ? 'badge-success' : rec.status === 'error' ? 'badge-error' : 'badge-warning'"
                  :title="rec.status"
                >{{ rec.status === 'success' ? '✓' : rec.status === 'error' ? '✗' : '!' }}</span>
                <span class="font-mono text-xs opacity-60 shrink-0">{{ formatTime(rec.timestamp) }}</span>
                <span class="font-medium truncate flex-1">
                  <span v-if="rec.server_name" class="opacity-70">{{ rec.server_name }}:</span>{{ rec.tool_name || rec.type }}
                </span>
                <span v-if="rec.duration_ms !== undefined" class="text-xs opacity-50 shrink-0">{{ rec.duration_ms }}ms</span>
              </li>
            </ul>
          </div>
        </section>
      </div>

      <!-- Footer (sticky, non-scrollable) -->
      <!-- Servers tab gets a dedicated import-action footer when at least one
           detectable server source exists. The action buttons stay visible
           even as the list above scrolls. Above the buttons sits a
           collapsed-by-default panel exposing global security settings
           (Docker isolation + per-server quarantine) so the user can opt
           into stricter or looser defaults without leaving the wizard. -->
      <div
        v-if="activeTab === 'servers' && importSourcesWithServers.length > 0"
        class="border-t border-base-300 shrink-0 bg-base-200/40"
      >
        <details class="border-b border-base-300" data-test="security-panel">
          <summary class="cursor-pointer flex items-center gap-2 px-6 py-2.5 select-none hover:bg-base-200/70 transition-colors">
            <span class="transition-transform inline-block group-open:rotate-90 opacity-60">▸</span>
            <span class="text-sm font-medium">Runtime isolation and MCP server quarantine</span>
            <span class="ml-auto inline-flex items-center gap-2">
              <span class="badge badge-primary badge-sm font-semibold">Global settings</span>
              <span class="text-xs opacity-70 hidden sm:inline">saved to your mcpproxy config</span>
            </span>
          </summary>
          <div class="px-6 py-4 space-y-4 bg-base-100">
            <!-- Docker isolation -->
            <label class="flex items-start gap-3 p-3 rounded-lg border border-base-300 cursor-pointer">
              <input
                type="checkbox"
                class="checkbox checkbox-sm mt-0.5"
                :checked="dockerIsolationDefault"
                :disabled="securityBusy || dockerStatus === false"
                @change="onToggleDockerIsolation(($event.target as HTMLInputElement).checked)"
                data-test="toggle-docker-isolation"
              />
              <div class="flex-1 min-w-0">
                <div class="font-medium text-sm">Docker isolation</div>
                <p class="text-xs opacity-70 mt-1 leading-relaxed">
                  Sandboxes every stdio server in a throwaway Docker container so a compromised server can't read or write your host files, env vars, or SSH keys. Recommended whenever you import servers from sources you don't fully control.
                </p>
                <p
                  v-if="dockerStatus === false"
                  class="text-xs text-warning mt-2"
                  data-test="docker-install-hint"
                >
                  Docker isn't running on this machine. Install
                  <a href="https://www.docker.com/products/docker-desktop/" target="_blank" rel="noopener" class="link">Docker Desktop</a>
                  (or start the Docker daemon) then come back to enable this — stdio servers run unsandboxed otherwise.
                </p>
                <p class="text-[11px] mt-2">
                  <a href="https://docs.mcpproxy.app/security/docker-isolation/" target="_blank" rel="noopener" class="link link-primary">Learn more about Docker isolation →</a>
                </p>
              </div>
            </label>

            <!-- Quarantine new servers -->
            <label class="flex items-start gap-3 p-3 rounded-lg border border-base-300 cursor-pointer">
              <input
                type="checkbox"
                class="checkbox checkbox-sm mt-0.5"
                :checked="quarantineEnabled"
                :disabled="securityBusy"
                @change="onToggleQuarantine(($event.target as HTMLInputElement).checked)"
                data-test="toggle-quarantine"
              />
              <div class="flex-1 min-w-0">
                <div class="font-medium text-sm">Quarantine new servers</div>
                <p class="text-xs mt-1 leading-relaxed">
                  <strong>Recommended.</strong> Holds every newly added server in a quarantine zone until you explicitly approve it. Defends against tool-poisoning attacks where a malicious server smuggles instructions into tool descriptions. <strong>Important:</strong> your AI agent itself can add upstream servers via mcpproxy's built-in MCP tools — your approval is the only safety net.
                </p>
                <p class="text-xs opacity-70 mt-1.5 leading-relaxed">
                  Combine with security scanners (Trivy, Semgrep, MCP Scan) on the
                  <router-link to="/servers" class="link link-primary">Servers</router-link>
                  page for deeper supply-chain checks before approving.
                </p>
                <p class="text-[11px] mt-2">
                  <a href="https://docs.mcpproxy.app/security/quarantine/" target="_blank" rel="noopener" class="link link-primary">Learn more about quarantine →</a>
                </p>
              </div>
            </label>
          </div>
        </details>

        <!-- Action footer -->
        <div class="flex items-center justify-between gap-3 px-6 py-3">
          <div class="text-xs">
            <span v-if="selectedCount === 0" class="opacity-50">Select at least one server to import</span>
            <span v-else>
              <span class="font-semibold text-primary">{{ selectedCount }}</span>
              <span class="opacity-70"> selected</span>
              <span v-if="conflictCount > 0" class="opacity-70">
                · <span class="text-warning">{{ conflictCount }} renamed</span>
              </span>
            </span>
          </div>
          <div class="flex items-center gap-2">
            <button
              class="btn btn-ghost btn-sm"
              @click="dismiss"
              data-test="close-wizard"
            >Close</button>
            <button
              class="btn btn-secondary btn-sm gap-1 min-w-[180px]"
              :disabled="selectedCount === 0 || importBusyAny"
              @click="onBulkImport(false)"
              data-test="bulk-import-active"
            >
              <span v-if="bulkImportBusy === 'active'" class="loading loading-spinner loading-xs"></span>
              <span v-else>⚡</span>
              Import as active
            </button>
            <button
              class="btn btn-primary btn-sm gap-1 min-w-[180px]"
              :disabled="selectedCount === 0 || importBusyAny || !quarantineEnabled"
              :title="!quarantineEnabled ? 'Re-enable Quarantine new servers above to use this option' : ''"
              @click="onBulkImport(true)"
              data-test="bulk-import-quarantine"
            >
              <span v-if="bulkImportBusy === 'quarantine'" class="loading loading-spinner loading-xs"></span>
              <span v-else>🛡</span>
              Import &amp; quarantine
            </button>
          </div>
        </div>
      </div>
      <!-- Default footer for other tabs / empty server state -->
      <div
        v-else
        class="flex items-center justify-between px-6 py-3 border-t border-base-300 shrink-0"
      >
        <div class="text-xs opacity-50">
          Tip: you can always re-open this from the sidebar's <span class="font-medium">Setup</span> entry.
        </div>
        <button class="btn btn-primary btn-sm" @click="dismiss" data-test="close-wizard">
          {{ onboarding.incompleteTabCount === 0 ? 'Done' : 'Close for now' }}
        </button>
      </div>
    </div>
    <form method="dialog" class="modal-backdrop" @click.prevent="dismiss"><button>close</button></form>
  </dialog>

  <!-- Embedded AddServerModal for the server tab -->
  <AddServerModal
    :show="addServerOpen"
    @close="addServerOpen = false"
    @added="onServerAdded"
  />
</template>

<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted, h, type FunctionalComponent } from 'vue'
import api from '@/services/api'
import { useOnboardingStore } from '@/stores/onboarding'
import { useSystemStore } from '@/stores/system'
import { useServersStore } from '@/stores/servers'
import AddServerModal from '@/components/AddServerModal.vue'
import type { ClientStatus, ActivityRecord, ConnectPreview } from '@/types'

interface Props {
  show: boolean
}

interface Emits {
  (e: 'close'): void
}

const props = defineProps<Props>()
const emit = defineEmits<Emits>()

const onboarding = useOnboardingStore()
const systemStore = useSystemStore()
const serversStore = useServersStore()

type TabID = 'clients' | 'servers' | 'verify'
const activeTab = ref<TabID>('clients')

const clients = ref<ClientStatus[]>([])
const loadingClients = ref(false)
const clientsError = ref<string | null>(null)
const busyClients = reactive<Record<string, boolean>>({})
const connectMessage = ref('')
const connectMessageOk = ref(true)
// Spec 078 US2 / FR-006: backup path per client for connects performed in this
// wizard session. string = timestamped backup created; null = success but no
// prior file existed (nothing to back up); absent = no connect happened yet.
const connectBackups = reactive<Record<string, string | null>>({})
const copiedBackupClient = ref<string | null>(null)
// Spec 078 US1: preview the exact change before writing. previews[id] present
// => the confirm/cancel panel is shown in that client's row; the write only
// happens on confirm. previewErrors holds a non-denial fetch failure.
const previews = reactive<Record<string, ConnectPreview>>({})
const previewBusy = reactive<Record<string, boolean>>({})
const previewErrors = reactive<Record<string, string>>({})
// Spec 078 US3: session-scoped one-click undo. undoPreviews snapshots the
// preview the user confirmed, so the revert panel can show the change about to
// be reverted (FR-009) without another config read. undoOpen[id] shows the
// confirm panel; the actual restore only happens on its confirm button.
const undoPreviews = reactive<Record<string, ConnectPreview>>({})
const undoOpen = reactive<Record<string, boolean>>({})
const undoBusy = reactive<Record<string, boolean>>({})
const addServerOpen = ref(false)
const serverAddedJustNow = ref(false)

const requireMcpAuth = ref(false)
const dockerIsolationDefault = ref(true)
const quarantineEnabled = ref(true)
const listenAddr = ref('')
const securityBusy = ref(false)
// null = not yet known (don't show warning); true/false = detected state
const dockerStatus = ref<boolean | null>(null)

// Spec 046 v2 — server-import preview rows.
interface ImportSource {
  name: string
  format: string
  path: string
  exists: boolean
  previewLoading: boolean
  previewError: string
  serverCount: number
  serverNames: string[]
}
const importSources = ref<ImportSource[]>([])
const loadingImportSources = ref(false)

// Verify tab — recent activity preview.
const recentActivity = ref<ActivityRecord[]>([])
const loadingActivity = ref(false)

// Selection: keyed by `${path}::${serverName}`. Default unchecked.
const selection = ref<Set<string>>(new Set())
const bulkImportBusy = ref<'' | 'quarantine' | 'active'>('')
const importBusyAny = computed(() => bulkImportBusy.value !== '')
const selectionImportMessage = ref('')
const selectionImportOk = ref(false)

const importSourcesWithServers = computed(() =>
  importSources.value.filter(s => s.serverCount > 0)
)

function selectionKey(path: string, name: string) {
  return `${path}::${name}`
}
function isSelected(path: string, name: string): boolean {
  return selection.value.has(selectionKey(path, name))
}
function toggleServer(path: string, name: string, on: boolean) {
  const key = selectionKey(path, name)
  // Vue's reactivity needs a fresh Set instance for refs holding Set/Map.
  const next = new Set(selection.value)
  if (on) next.add(key)
  else next.delete(key)
  selection.value = next
}
function isAllSelected(src: ImportSource): boolean {
  return src.serverNames.length > 0 && src.serverNames.every(n => isSelected(src.path, n))
}
function isIndeterminate(src: ImportSource): boolean {
  const some = src.serverNames.some(n => isSelected(src.path, n))
  return some && !isAllSelected(src)
}
function toggleAllInSource(src: ImportSource, on: boolean) {
  const next = new Set(selection.value)
  for (const n of src.serverNames) {
    const key = selectionKey(src.path, n)
    if (on) next.add(key)
    else next.delete(key)
  }
  selection.value = next
}

const selectedCount = computed(() => selection.value.size)

// Build the conflict map across SELECTED servers only. A name is conflicted
// iff it is selected from 2+ sources. Conflicted servers are renamed to
// `<originalName>_<format>` so the resulting mcpproxy entries are distinct.
const conflictTargets = computed<Map<string, string>>(() => {
  // name -> Set<path> for selected servers
  const namesToPaths = new Map<string, Set<string>>()
  for (const key of selection.value) {
    const sep = key.indexOf('::')
    if (sep === -1) continue
    const path = key.slice(0, sep)
    const name = key.slice(sep + 2)
    if (!namesToPaths.has(name)) namesToPaths.set(name, new Set())
    namesToPaths.get(name)!.add(path)
  }
  // For each name selected from 2+ sources, mark all (path, name) → renamed.
  const out = new Map<string, string>() // selectionKey -> newName
  for (const [name, paths] of namesToPaths) {
    if (paths.size < 2) continue
    for (const path of paths) {
      const src = importSources.value.find(s => s.path === path)
      if (!src) continue
      out.set(selectionKey(path, name), `${name}_${src.format.replace(/-/g, '_')}`)
    }
  }
  return out
})
function conflictTarget(src: ImportSource, name: string): string | undefined {
  return conflictTargets.value.get(selectionKey(src.path, name))
}
const conflictCount = computed(() => conflictTargets.value.size)

let pollHandle: ReturnType<typeof setInterval> | null = null

// Tabs config
const tabs = computed(() => [
  {
    id: 'clients' as TabID,
    label: 'Clients',
    idx: 1,
    complete: onboarding.hasConnectedClient,
  },
  {
    id: 'servers' as TabID,
    label: 'Servers',
    idx: 2,
    complete: onboarding.hasConfiguredServer,
  },
  {
    id: 'verify' as TabID,
    label: 'Verify',
    idx: 3,
    complete: onboarding.firstMCPClientEver,
  },
])

// Modal sizing per spec FR-V10
const modalSizing = computed(() => ({
  width: 'min(960px, 90vw)',
  maxWidth: 'min(960px, 90vw)',
  height: 'min(640px, 85vh)',
  maxHeight: 'min(640px, 85vh)',
}))

// --- Client sort: detected → pinned trio → others -----------------------
const PINNED_TRIO: readonly string[] = ['claude-code', 'codex', 'gemini']

// MCP-2952: the `GET /api/v1/connect` listing is stat-only (#706/MCP-2829) and
// always reports connected=false. Merge the content-resolved
// connected_client_ids the wizard already fetched via onboarding.fetchState()
// so connected clients render the Connected badge instead of a Connect button.
// Derived (not mutated) so polling refreshes stay correct.
const mergedClients = computed<ClientStatus[]>(() => {
  const connectedIds = new Set(onboarding.connectedClientIds)
  return clients.value.map(c =>
    c.connected || !connectedIds.has(c.id) ? c : { ...c, connected: true }
  )
})

const detectedClients = computed(() =>
  mergedClients.value.filter(c => c.exists)
)
const pinnedClients = computed(() =>
  PINNED_TRIO
    .map(id => mergedClients.value.find(c => c.id === id))
    .filter((c): c is ClientStatus => !!c && !c.exists)
)
const moreClients = computed(() => {
  const detectedIds = new Set(detectedClients.value.map(c => c.id))
  const pinnedIds = new Set(pinnedClients.value.map(c => c.id))
  return mergedClients.value.filter(c => !detectedIds.has(c.id) && !pinnedIds.has(c.id))
})

const serverCountLabel = computed(() => {
  const n = onboarding.state?.configured_server_count ?? 0
  return n === 1 ? '1 server' : `${n} servers`
})

// Open lifecycle: refresh state, fetch clients + config, start polling.
watch(() => props.show, async (open) => {
  if (open) {
    serverAddedJustNow.value = false
    connectMessage.value = ''
    // Backup lines are session-scoped (Spec 078 US2): don't replay backup
    // rows from connects performed in a previous wizard session.
    for (const k of Object.keys(connectBackups)) delete connectBackups[k]
    copiedBackupClient.value = null
    // Spec 078 US1: previews are session-scoped too — don't replay a stale
    // confirm/cancel panel from a previous wizard session.
    for (const k of Object.keys(previews)) delete previews[k]
    for (const k of Object.keys(previewErrors)) delete previewErrors[k]
    // Spec 078 US3: undo is session-scoped (it reverts the connect performed
    // in THIS wizard session) — a reopened wizard starts without undo state.
    for (const k of Object.keys(undoPreviews)) delete undoPreviews[k]
    for (const k of Object.keys(undoOpen)) delete undoOpen[k]
    await onboarding.fetchState()
    await Promise.all([
      fetchClients(),
      fetchSecurityState(),
      fetchDockerStatus(),
      fetchImportSources(),
      fetchRecentActivity(),
    ])
    activeTab.value = pickInitialTab()
    startPolling()
  } else {
    stopPolling()
  }
})

function pickInitialTab(): TabID {
  if (!onboarding.hasConnectedClient) return 'clients'
  if (!onboarding.hasConfiguredServer) return 'servers'
  if (!onboarding.firstMCPClientEver) return 'verify'
  return 'clients'
}

function startPolling() {
  stopPolling()
  // Poll while wizard is open so the Verify tab flips to green within ~5s
  // of the AfterInitialize hook firing without an SSE channel. Also
  // refresh the Verify tab's recent-activity panel on the same cadence so
  // newly-arrived MCP requests are reflected without manual reload.
  pollHandle = setInterval(() => {
    void onboarding.fetchState()
    if (activeTab.value === 'verify') {
      void fetchRecentActivity()
    }
  }, 5000)
}

function stopPolling() {
  if (pollHandle) {
    clearInterval(pollHandle)
    pollHandle = null
  }
}

onUnmounted(() => stopPolling())

async function fetchClients() {
  loadingClients.value = true
  clientsError.value = null
  try {
    const res = await api.getConnectStatus()
    if (res.success && res.data) {
      clients.value = Array.isArray(res.data) ? res.data : []
    } else {
      clientsError.value = res.error ?? 'Failed to load client status'
    }
  } catch (err) {
    clientsError.value = (err as Error).message
  } finally {
    loadingClients.value = false
  }
}

async function fetchRecentActivity() {
  loadingActivity.value = true
  try {
    const res = await api.getActivities({ limit: 5 })
    if (res.success && res.data) {
      recentActivity.value = res.data.activities ?? []
    }
  } catch {
    // graceful — keep prior list
  } finally {
    loadingActivity.value = false
  }
}

function formatTime(ts: string): string {
  const d = new Date(ts)
  const now = Date.now()
  const diff = now - d.getTime()
  if (diff < 60_000) return 'just now'
  if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
  if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
  return d.toLocaleDateString()
}

async function fetchSecurityState() {
  try {
    const res = await api.getConfig()
    if (res.success && res.data) {
      const cfg = res.data.config ?? {}
      requireMcpAuth.value = !!cfg.require_mcp_auth
      quarantineEnabled.value = cfg.quarantine_enabled ?? true
      listenAddr.value = cfg.listen ?? ''
      const iso = cfg.docker_isolation ?? cfg.isolation ?? null
      dockerIsolationDefault.value = iso?.enabled ?? true
    }
  } catch {
    // graceful degradation
  }
}

async function fetchDockerStatus() {
  // Spec 046 v2: feed the Docker isolation toggle's "is Docker actually
  // available?" warning. We mirror the Dashboard's logic so the message is
  // consistent: if Docker reports unavailable but at least one connected
  // stdio server is running, treat as available (the Docker health checker
  // can lag behind reality).
  try {
    const res = await api.getDockerStatus()
    if (res.success && res.data) {
      let available = res.data.docker_available ?? false
      if (!available && serversStore.servers.some(s => s.connected && s.protocol === 'stdio')) {
        available = true
      }
      dockerStatus.value = available
    } else {
      dockerStatus.value = false
    }
  } catch {
    dockerStatus.value = false
  }
}

async function fetchImportSources() {
  // Spec 046 v2: parity with the Clients tab. Discover canonical client
  // config paths, then for each existing one, run a no-side-effect preview
  // to count importable servers.
  loadingImportSources.value = true
  try {
    const res = await api.getCanonicalConfigPaths()
    if (!res.success || !res.data) {
      importSources.value = []
      return
    }
    const sources: ImportSource[] = res.data.paths
      .filter(p => p.exists)
      .map(p => ({
        name: p.name,
        format: p.format,
        path: p.path,
        exists: true,
        previewLoading: true,
        previewError: '',
        serverCount: 0,
        serverNames: [],
        importBusy: '',
        importMessage: '',
        importMessageOk: false,
      }))
    importSources.value = sources

    // Run previews in parallel — each is bounded by the user's local file
    // size, so this is safe even with many sources.
    await Promise.all(
      sources.map(async (src, idx) => {
        try {
          const r = await api.importServersFromPath({
            path: src.path,
            format: src.format,
            preview: true,
          })
          if (r.success && r.data) {
            const imported = r.data.imported ?? []
            importSources.value[idx] = {
              ...src,
              previewLoading: false,
              serverCount: imported.length,
              serverNames: imported.map(s => s.name),
            }
          } else {
            importSources.value[idx] = {
              ...src,
              previewLoading: false,
              previewError: r.error ?? 'preview failed',
            }
          }
        } catch (err) {
          importSources.value[idx] = {
            ...src,
            previewLoading: false,
            previewError: (err as Error).message,
          }
        }
      })
    )
  } finally {
    loadingImportSources.value = false
  }
}

async function onBulkImport(quarantine: boolean) {
  if (selection.value.size === 0) return
  bulkImportBusy.value = quarantine ? 'quarantine' : 'active'
  selectionImportMessage.value = ''

  // Group selected (path, name) pairs by source path. Build per-source
  // server_names + rename map (only for entries flagged as conflicts).
  type Job = {
    src: ImportSource
    serverNames: string[]
    rename: Record<string, string>
  }
  const jobs = new Map<string, Job>()
  for (const key of selection.value) {
    const sep = key.indexOf('::')
    if (sep === -1) continue
    const path = key.slice(0, sep)
    const name = key.slice(sep + 2)
    const src = importSources.value.find(s => s.path === path)
    if (!src) continue
    if (!jobs.has(path)) {
      jobs.set(path, { src, serverNames: [], rename: {} })
    }
    const job = jobs.get(path)!
    job.serverNames.push(name)
    const renamed = conflictTargets.value.get(key)
    if (renamed) job.rename[name] = renamed
  }

  let totalImported = 0
  let totalSkipped = 0
  let totalFailed = 0
  const errors: string[] = []
  try {
    const results = await Promise.all(
      Array.from(jobs.values()).map(async job => {
        const r = await api.importServersFromPath({
          path: job.src.path,
          format: job.src.format,
          server_names: job.serverNames,
          rename: Object.keys(job.rename).length > 0 ? job.rename : undefined,
          skip_quarantine: !quarantine,
        })
        return { job, r }
      })
    )
    for (const { job, r } of results) {
      if (r.success && r.data) {
        totalImported += r.data.summary?.imported ?? 0
        totalSkipped += r.data.summary?.skipped ?? 0
        totalFailed += r.data.summary?.failed ?? 0
      } else {
        errors.push(`${job.src.name}: ${r.error ?? 'unknown error'}`)
      }
    }
    selectionImportOk.value = errors.length === 0
    if (errors.length === 0) {
      const dest = quarantine ? 'into quarantine' : 'as active'
      let msg = `✓ Imported ${totalImported} server${totalImported === 1 ? '' : 's'} ${dest}`
      if (totalSkipped > 0) msg += ` · ${totalSkipped} skipped (already configured)`
      if (totalFailed > 0) msg += ` · ${totalFailed} failed`
      if (quarantine && totalImported > 0) msg += '. Approve from the Servers page.'
      selectionImportMessage.value = msg
      selection.value = new Set()
      // Spec 046 — bulk-import counts as completing the server step.
      if (totalImported > 0 && onboarding.state?.state.server_step_status !== 'completed') {
        await onboarding.markServerCompleted()
      }
      systemStore.addToast({
        type: 'success',
        title: 'Import complete',
        message: `${totalImported} server${totalImported === 1 ? '' : 's'}${conflictCount.value > 0 ? ` (${conflictCount.value} renamed)` : ''}`,
      })
    } else {
      selectionImportMessage.value = `Some imports failed: ${errors.join(' · ')}`
    }
    await Promise.all([
      serversStore.fetchServers(),
      onboarding.fetchState(),
      // Refresh previews so already-imported servers drop off the list.
      fetchImportSources(),
    ])
  } catch (err) {
    selectionImportMessage.value = (err as Error).message
    selectionImportOk.value = false
  } finally {
    bulkImportBusy.value = ''
  }
}

async function patchConfig(patch: Record<string, unknown>) {
  // /api/v1/config/apply decodes the body directly into config.Config (no
  // {config: ...} wrapper). Settings.vue calls applyConfig(config) the same
  // bare way; the wizard's earlier {config: merged} wrap was sending an
  // unrelated outer object that the backend rejected with a stale field-
  // validation error ("tools_limit: must be between 1 and 1000"). Send
  // the bare config to match the existing contract.
  securityBusy.value = true
  try {
    const cur = await api.getConfig()
    if (!cur.success || !cur.data) {
      throw new Error(cur.error ?? 'failed to read config')
    }
    const merged = { ...cur.data.config, ...patch }
    const res = await api.applyConfig(merged)
    if (!res.success) {
      throw new Error(res.error ?? 'failed to apply config')
    }
    await fetchSecurityState()
    systemStore.addToast({ type: 'success', title: 'Settings saved', message: 'Updated' })
  } catch (err) {
    systemStore.addToast({
      type: 'error',
      title: 'Save failed',
      message: (err as Error).message,
    })
  } finally {
    securityBusy.value = false
  }
}

function onToggleRequireAuth(v: boolean) {
  void patchConfig({ require_mcp_auth: v })
}

function onToggleDockerIsolation(v: boolean) {
  // Toggle the global docker_isolation default. Per-server overrides are
  // unaffected. The Server tab's per-server form remains the source of
  // truth for granular control.
  void patchConfig({ docker_isolation: { enabled: v } })
}

function onToggleQuarantine(v: boolean) {
  void patchConfig({ quarantine_enabled: v })
}

// Spec 078 US1: the row's Connect fetches the preview first and opens the
// confirm/cancel panel — nothing is written until confirm. A denied read is
// surfaced as an error message (the wizard has no separate access banner yet;
// the standalone Connect modal carries the full Spec 075 remediation surface).
async function startConnect(clientId: string) {
  previewBusy[clientId] = true
  delete previewErrors[clientId]
  try {
    const res = await api.getConnectPreview(clientId)
    if (res.success && res.data) {
      previews[clientId] = res.data
    } else {
      previewErrors[clientId] = res.error || 'Failed to load preview'
    }
  } catch (err) {
    previewErrors[clientId] = (err as Error).message
  } finally {
    previewBusy[clientId] = false
  }
}

// Cancel dismisses the preview WITHOUT writing anything.
function cancelPreview(clientId: string) {
  delete previews[clientId]
  delete previewErrors[clientId]
}

// Confirm proceeds; an existing same-named entry implies force=true (the user
// saw the overwrite warning in the preview).
async function confirmConnect(clientId: string) {
  const preview = previews[clientId]
  const force = preview?.entry_exists === true
  delete previews[clientId]
  delete previewErrors[clientId]
  await connectOne(clientId, force, preview)
}

async function connectOne(clientId: string, force = false, preview?: ConnectPreview) {
  busyClients[clientId] = true
  connectMessage.value = ''
  try {
    const res = await api.connectClient(clientId, 'mcpproxy', force)
    if (res.success && res.data) {
      connectMessageOk.value = true
      connectMessage.value = res.data.message || `Connected ${clientId}`
      // Spec 078 US2: keep the backup path so the row can surface it; an
      // empty/absent backup_path on success means no prior file existed.
      connectBackups[clientId] = res.data.backup_path || null
      // Spec 078 US3: remember what was written so the Undo panel can show
      // the change it is about to revert (FR-009).
      if (preview) undoPreviews[clientId] = preview
      delete undoOpen[clientId]
      await fetchClients()
      // Spec 046 — record connect-step completion for telemetry funnel.
      // Only the first successful connect transitions the status; subsequent
      // calls are no-ops on the backend.
      if (onboarding.state?.state.connect_step_status !== 'completed') {
        await onboarding.markConnectCompleted()
      }
      await onboarding.fetchState()
      systemStore.addToast({
        type: 'success',
        title: 'Client connected',
        message: `mcpproxy registered in ${clientId}`,
      })
    } else {
      connectMessageOk.value = false
      connectMessage.value = res.error ?? 'Failed to connect'
    }
  } catch (err) {
    connectMessageOk.value = false
    connectMessage.value = (err as Error).message
  } finally {
    busyClients[clientId] = false
  }
}

// Spec 078 US2: one-click copy of a row's backup path (same clipboard pattern
// as ConnectModal's copy affordances).
async function copyBackupPath(clientId: string) {
  const path = connectBackups[clientId]
  if (!path) return
  try {
    await navigator.clipboard.writeText(path)
    copiedBackupClient.value = clientId
    setTimeout(() => {
      if (copiedBackupClient.value === clientId) copiedBackupClient.value = null
    }, 2000)
  } catch {
    // Clipboard unavailable: the full path is already rendered in the row.
  }
}

// Spec 078 US3 / FR-009: Undo shows the change to be reverted first; the
// restore only runs from the panel's confirm button.
function startUndo(clientId: string) {
  undoOpen[clientId] = true
}

function cancelUndo(clientId: string) {
  delete undoOpen[clientId]
}

async function confirmUndo(clientId: string) {
  undoBusy[clientId] = true
  connectMessage.value = ''
  try {
    const res = await api.undoConnectClient(clientId, 'mcpproxy', connectBackups[clientId] ?? null)
    if (res.success && res.data) {
      connectMessageOk.value = true
      connectMessage.value = res.data.message || `Undid the ${clientId} connect`
      delete connectBackups[clientId]
      delete undoPreviews[clientId]
      delete undoOpen[clientId]
      await fetchClients()
      // Telemetry (FR-016): connect_step completion is a one-time funnel
      // transition and is deliberately NOT retracted on undo — the user did
      // complete the step; undo is a reversal of the file change, not of the
      // funnel event.
      await onboarding.fetchState()
      systemStore.addToast({
        type: 'info',
        title: 'Connect undone',
        message: `${clientId} restored to its pre-connect state`,
      })
    } else {
      // Honest refusal (e.g. the config changed since the connect): show the
      // backend's message and keep the row's state so the user can decide.
      connectMessageOk.value = false
      connectMessage.value = res.error ?? 'Failed to undo the connect'
      delete undoOpen[clientId]
    }
  } catch (err) {
    connectMessageOk.value = false
    connectMessage.value = (err as Error).message
    delete undoOpen[clientId]
  } finally {
    undoBusy[clientId] = false
  }
}

function openAddServer() {
  addServerOpen.value = true
}

async function onServerAdded() {
  addServerOpen.value = false
  serverAddedJustNow.value = true
  // Spec 046 — record server-step completion for telemetry funnel.
  if (onboarding.state?.state.server_step_status !== 'completed') {
    await onboarding.markServerCompleted()
  }
  await Promise.all([
    serversStore.fetchServers(),
    onboarding.fetchState(),
  ])
  systemStore.addToast({
    type: 'success',
    title: 'Server added',
    message: 'It is in quarantine. Review and approve from the Servers page.',
  })
}

async function dismiss() {
  // Engagement is permanent: once the wizard has been opened and dismissed,
  // we don't auto-popup again. The sidebar Setup entry remains visible so
  // the user can return.
  if (!onboarding.isEngaged) {
    // Spec 046 — any step the user never advanced through counts as "skipped"
    // so the funnel (engaged - completed - skipped) reconciles to engaged
    // total. Per-step calls are no-ops if a status is already recorded.
    const stepState = onboarding.state?.state
    if (stepState && !stepState.connect_step_status) {
      await onboarding.markConnectSkipped()
    }
    if (stepState && !stepState.server_step_status) {
      await onboarding.markServerSkipped()
    }
    await onboarding.markEngaged()
  }
  emit('close')
}

onMounted(() => {
  // If wizard is already open at mount time (rare; usually opened reactively
  // via :show), kick off initial load.
  if (props.show) {
    void onboarding.fetchState()
    void fetchClients()
    void fetchSecurityState()
    void fetchDockerStatus()
    void fetchImportSources()
    startPolling()
  }
})

// --- ClientRow component ------------------------------------------------
// Inlined as a functional component to keep this file self-contained while
// the row layout stays consistent across all three lists.
const ClientRow: FunctionalComponent<
  {
    client: ClientStatus
    busy?: boolean
    backupPath?: string | null
    copiedBackup?: boolean
    preview?: ConnectPreview
    previewBusy?: boolean
    previewError?: string
    undoPreview?: ConnectPreview
    undoOpen?: boolean
    undoBusy?: boolean
  },
  {
    connect: (id: string) => void
    'copy-backup': (id: string) => void
    confirm: (id: string) => void
    cancel: (id: string) => void
    undo: (id: string) => void
    'confirm-undo': (id: string) => void
    'cancel-undo': (id: string) => void
  }
> = (props, { emit: rowEmit }) => {
  const c = props.client
  const row = h(
    'div',
    { class: 'flex items-center justify-between' },
    [
      h('div', { class: 'min-w-0 flex-1' }, [
        h('div', { class: 'font-medium text-sm truncate' }, c.name),
        h('div', { class: 'text-xs opacity-50 truncate', title: c.config_path }, c.config_path),
      ]),
      h('div', { class: 'shrink-0 ml-2' }, [
        !c.supported
          ? h('span', { class: 'badge badge-ghost badge-sm' }, c.reason || 'Not supported')
          // Bridge clients (e.g. Claude Desktop) are connectable even without
          // an existing config file — Connect creates it (parity with
          // ConnectModal's connectableClients gating; Spec 078 US2/FR-006:
          // this is the path that produces the "no prior file" backup case).
          : !c.exists && !c.bridge
            ? h('span', { class: 'text-xs opacity-40' }, 'Not installed')
            : c.connected
              ? h('span', { class: 'badge badge-success badge-sm' }, 'Connected')
              : h(
                  'button',
                  {
                    class: 'btn btn-primary btn-xs',
                    disabled: props.busy || props.previewBusy,
                    'data-test': `connect-${c.id}`,
                    onClick: () => rowEmit('connect', c.id),
                  },
                  props.busy || props.previewBusy
                    ? [h('span', { class: 'loading loading-spinner loading-xs' })]
                    : ['Review & connect']
                ),
      ]),
    ]
  )
  // Spec 078 US2 / FR-006: after a connect performed in this wizard session,
  // surface the timestamped backup (or the honest "no prior file" case) right
  // in the row. undefined = no connect happened for this client yet.
  // Spec 078 US3: the same line carries the one-click Undo affordance —
  // reverting is offered exactly where the change was reported.
  const undoButton = h(
    'button',
    {
      class: 'btn btn-ghost btn-xs shrink-0 text-error',
      disabled: props.undoBusy,
      'data-test': `client-undo-${c.id}`,
      title: 'Revert this connect: restore the config to its pre-connect state',
      onClick: () => rowEmit('undo', c.id),
    },
    'Undo'
  )
  const backupLine =
    props.backupPath !== undefined
      ? h(
          'div',
          {
            class: 'mt-1 flex items-start justify-between gap-2 text-[11px] opacity-70',
            'data-test': `client-backup-${c.id}`,
          },
          props.backupPath
            ? [
                h('span', { class: 'min-w-0 break-all leading-relaxed' }, [
                  'A backup of your previous config was saved to ',
                  h('code', { class: 'font-mono', title: props.backupPath }, props.backupPath),
                ]),
                h('span', { class: 'flex items-center shrink-0' }, [
                  h(
                    'button',
                    {
                      class: 'btn btn-ghost btn-xs shrink-0',
                      'data-test': `client-copy-backup-${c.id}`,
                      title: 'Copy the backup path to the clipboard',
                      onClick: () => rowEmit('copy-backup', c.id),
                    },
                    props.copiedBackup ? 'Copied ✓' : 'Copy path'
                  ),
                  undoButton,
                ]),
              ]
            : [
                h(
                  'span',
                  { class: 'min-w-0 leading-relaxed' },
                  'No prior config file existed, so no backup was needed.'
                ),
                undoButton,
              ]
        )
      : null

  // Spec 078 US3 / FR-009: before reverting, show the change that will be
  // undone (the entry the confirmed preview wrote). Confirm/Keep gate the
  // actual restore; Keep changes nothing.
  const up = props.undoPreview
  const undoPanel = props.undoOpen
    ? h(
        'div',
        {
          class: 'mt-2 rounded-lg bg-base-200/60 border border-error/40 px-3 py-2 space-y-2',
          'data-test': `client-undo-panel-${c.id}`,
        },
        [
          h(
            'p',
            { class: 'text-xs opacity-70 leading-relaxed' },
            props.backupPath
              ? [
                  'Undo restores ',
                  h('code', { class: 'font-mono text-[11px] break-all' }, up?.config_path ?? c.config_path),
                  ' to its exact pre-connect state from the backup (a safety copy of the current file is saved first).',
                ]
              : [
                  'Undo removes ',
                  h('code', { class: 'font-mono text-[11px] break-all' }, up?.config_path ?? c.config_path),
                  ' — it did not exist before mcpproxy connected (a safety copy is saved first).',
                ]
          ),
          up
            ? h('div', {}, [
                h(
                  'div',
                  { class: 'text-[11px] font-semibold uppercase tracking-wider text-error/80 mb-1' },
                  '− will be reverted'
                ),
                h(
                  'pre',
                  {
                    class: 'text-[11px] font-mono whitespace-pre-wrap break-all rounded bg-base-300/60 border-l-2 border-error px-2 py-1.5 leading-relaxed',
                    'data-test': `client-undo-entry-${c.id}`,
                  },
                  up.entry_text
                ),
              ])
            : null,
          h('div', { class: 'flex items-center gap-2 pt-0.5' }, [
            h(
              'button',
              {
                class: 'btn btn-error btn-xs',
                disabled: props.undoBusy,
                'data-test': `client-undo-confirm-${c.id}`,
                onClick: () => rowEmit('confirm-undo', c.id),
              },
              props.undoBusy ? [h('span', { class: 'loading loading-spinner loading-xs' })] : ['Undo connect']
            ),
            h(
              'button',
              {
                class: 'btn btn-ghost btn-xs',
                disabled: props.undoBusy,
                'data-test': `client-undo-cancel-${c.id}`,
                onClick: () => rowEmit('cancel-undo', c.id),
              },
              'Keep'
            ),
          ]),
        ]
      )
    : null

  // Spec 078 US1 / FR-001,003,004: preview the exact change before writing.
  // Only this entry is added; everything else in the file is untouched. The
  // Connect/Cancel buttons gate the actual write (Cancel writes nothing).
  const p = props.preview
  const previewPanel = p
    ? h(
        'div',
        {
          class: 'mt-2 rounded-lg bg-base-200/60 border border-base-300 px-3 py-2 space-y-2',
          'data-test': `client-preview-${c.id}`,
        },
        [
          h('p', { class: 'text-xs opacity-70 leading-relaxed' }, [
            'Only this entry is added to ',
            h('code', { class: 'font-mono text-[11px] break-all', title: p.config_path }, p.config_path),
            '. Everything else in the file stays untouched, and a timestamped backup is created first.',
          ]),
          p.entry_exists
            ? h(
                'p',
                { class: 'text-xs text-warning leading-relaxed', 'data-test': `client-preview-overwrite-${c.id}` },
                `An entry named "${p.server_name}" already exists — connecting will overwrite it (a backup is saved first).`
              )
            : p.access_state === 'malformed'
              ? h(
                  'p',
                  { class: 'text-xs text-warning leading-relaxed', 'data-test': `client-preview-malformed-${c.id}` },
                  `Your current config could not be parsed, so connecting would fail rather than modify an unreadable file. Fix or remove ${p.config_path} first, then try again.`
                )
              : p.access_state === 'absent'
                ? h(
                    'p',
                    { class: 'text-xs opacity-60 leading-relaxed', 'data-test': `client-preview-no-file-${c.id}` },
                    'This file will be created; there is no prior file to back up.'
                  )
                : null,
          h('div', {}, [
            h('div', { class: 'text-[11px] font-semibold uppercase tracking-wider text-success/80 mb-1' }, '+ will be added'),
            h(
              'pre',
              {
                class: 'text-[11px] font-mono whitespace-pre-wrap break-all rounded bg-base-300/60 border-l-2 border-success px-2 py-1.5 leading-relaxed',
                'data-test': `client-preview-entry-${c.id}`,
              },
              p.entry_text
            ),
          ]),
          p.contains_api_key
            ? h(
                'p',
                { class: 'text-[11px] opacity-60 leading-relaxed', 'data-test': `client-preview-apikey-${c.id}` },
                'This entry includes your API key (shown masked). The real key is written into the config so the client can authenticate.'
              )
            : null,
          h('div', { class: 'flex items-center gap-2 pt-0.5' }, [
            h(
              'button',
              {
                class: 'btn btn-primary btn-xs',
                disabled: props.busy || p.access_state === 'malformed',
                'data-test': `client-preview-confirm-${c.id}`,
                onClick: () => rowEmit('confirm', c.id),
              },
              props.busy ? [h('span', { class: 'loading loading-spinner loading-xs' })] : ['Connect']
            ),
            h(
              'button',
              {
                class: 'btn btn-ghost btn-xs',
                disabled: props.busy,
                'data-test': `client-preview-cancel-${c.id}`,
                onClick: () => rowEmit('cancel', c.id),
              },
              'Cancel'
            ),
          ]),
        ]
      )
    : null

  // Non-denial preview fetch failure (the wizard has no separate access banner;
  // the standalone Connect modal carries the full Spec 075 remediation surface).
  const previewErrorLine =
    !p && props.previewError
      ? h(
          'p',
          { class: 'mt-2 text-xs text-error', 'data-test': `client-preview-error-${c.id}` },
          props.previewError
        )
      : null

  const children = [row]
  if (backupLine) children.push(backupLine)
  if (undoPanel) children.push(undoPanel)
  if (previewPanel) children.push(previewPanel)
  if (previewErrorLine) children.push(previewErrorLine)
  return h(
    'div',
    {
      class: 'p-2 rounded-lg border border-base-300',
      'data-test': `client-row-${c.id}`,
    },
    children
  )
}
ClientRow.props = {
  client: { type: Object, required: true },
  busy: { type: Boolean, default: false },
  backupPath: { type: String, default: undefined },
  copiedBackup: { type: Boolean, default: false },
  preview: { type: Object, default: undefined },
  previewBusy: { type: Boolean, default: false },
  previewError: { type: String, default: undefined },
  undoPreview: { type: Object, default: undefined },
  undoOpen: { type: Boolean, default: false },
  undoBusy: { type: Boolean, default: false },
}
ClientRow.emits = ['connect', 'copy-backup', 'confirm', 'cancel', 'undo', 'confirm-undo', 'cancel-undo']
</script>
