<script lang="ts">
  import type { PendingApproval } from '$lib/stores/chat'
  import { decideApproval, getExecution, fetchBlastRadius, type Execution } from '$lib/api'
  import { Button } from '$lib/components/ui/button'
  import { SvelteMap } from 'svelte/reactivity'
  import CheckIcon from '@lucide/svelte/icons/check'
  import XIcon from '@lucide/svelte/icons/x'
  import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
  import Spinner from './Spinner.svelte'
  import NetworkIcon from '@lucide/svelte/icons/network'

  let { approvals }: { approvals: PendingApproval[] } = $props()

  // Downstream entities the target affects, keyed by executionId — fetched
  // once per approval so the operator sees the graph-walk impact ("this
  // affects 3 downstream") before deciding, not after. depth 0 is the target
  // itself, excluded here since it's already shown as "on {target}".
  const blastRadius = new SvelteMap<string, string[]>()
  const blastRadiusFetched = new Set<string>()
  async function loadBlastRadius(a: PendingApproval) {
    if (blastRadiusFetched.has(a.executionId) || a.target === 'unknown') return
    blastRadiusFetched.add(a.executionId)
    const items = await fetchBlastRadius(a.target)
    const affected = items.filter((i) => i.depth > 0).map((i) => i.entity.slug)
    if (affected.length) blastRadius.set(a.executionId, affected)
  }

  // Per-execution UI phase, keyed by executionId. A resolved phase hides the
  // action buttons permanently so the banner clears after a click and can
  // never re-POST /decision.
  type Phase = 'deciding' | 'running' | 'completed' | 'failed' | 'denied' | 'stalled'
  const phase = new SvelteMap<string, Phase>()
  // Latest execution row (for status/result display), keyed by executionId.
  const exec = new SvelteMap<string, Execution>()

  const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'denied'])

  function errorText(e: Execution | undefined): string {
    const r = e?.result as Record<string, unknown> | undefined | null
    const v = r?.error
    return typeof v === 'string' && v ? v : 'Execution failed.'
  }

  function outputText(e: Execution | undefined): string {
    const r = e?.result as Record<string, unknown> | undefined | null
    const v = r?.output
    return typeof v === 'string' ? v.trim() : ''
  }

  function elapsedSeconds(e: Execution | undefined): number | null {
    if (!e?.created_at) return null
    return Math.max(0, Math.round((now - new Date(e.created_at).getTime()) / 1000))
  }

  function fmtDuration(s: number): string {
    if (s < 60) return `${s}s`
    const m = Math.floor(s / 60)
    return `${m}m ${s % 60}s`
  }

  // Live clock for the elapsed-time display on running cards. Tied to
  // component lifecycle via $effect so the interval is guaranteed cleared on
  // unmount — a bare setInterval field here would leak a 1Hz timer for the
  // lifetime of the page every time this component was mounted.
  let now = $state(Date.now())
  $effect(() => {
    const t = setInterval(() => { now = Date.now() }, 1000)
    return () => clearInterval(t)
  })

  // Backend commands are hard-capped at 10 minutes (internal sshExec
  // timeout) before the execution is force-finalized as failed — so polling
  // must outlast that with margin, or the UI gives up and goes stale before
  // the backend ever resolves. Poll for 14 minutes; anything still running
  // past that is a genuine anomaly worth surfacing distinctly rather than
  // silently going quiet.
  const POLL_CEILING_MS = 14 * 60 * 1000

  // Poll the execution until it reaches a terminal state, so the operator sees
  // provisioning progress and the final outcome without leaving the chat.
  async function track(id: string) {
    const deadline = Date.now() + POLL_CEILING_MS
    while (Date.now() < deadline) {
      const e = await getExecution(id)
      if (e) {
        exec.set(id, e)
        if (e.status === 'completed') { phase.set(id, 'completed'); return }
        if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
        if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
      }
      await new Promise((r) => setTimeout(r, 2500))
    }
    // Genuinely outlasted the backend's own hard timeout — this means
    // something is wrong beyond a slow command (e.g. the API is down).
    // Say so explicitly instead of freezing on "running" with no signal.
    if (!TERMINAL.has(exec.get(id)?.status ?? '')) phase.set(id, 'stalled')
  }

  async function decide(approval: PendingApproval, decision: 'approve' | 'deny') {
    const id = approval.executionId
    const cur = phase.get(id)
    if (cur && cur !== 'failed') return // no resubmit once decided/in-flight
    phase.set(id, 'deciding')
    const ok = await decideApproval(id, decision)
    if (!ok) { phase.set(id, 'failed'); return }
    if (decision === 'deny') { phase.set(id, 'denied'); return }
    phase.set(id, 'running')
    void track(id)
  }

  // Self-heal: a pending approval can be decided somewhere other than this
  // button — chat assent ("go ahead" in the next message), the Ops page, or
  // Matrix. Without this, the banner would sit showing Approve/Deny forever
  // while the action was already running or done behind the scenes. Poll
  // every card that's still showing buttons; the moment its execution leaves
  // pending_approval, adopt that outcome exactly as if the button had been
  // clicked. Stops immediately if the operator clicks the button first
  // (phase becomes non-empty, ending this loop's reason to exist).
  const watching = new Set<string>()
  async function watchExternal(id: string) {
    if (watching.has(id)) return
    watching.add(id)
    for (let i = 0; i < 200; i++) { // ~10min ceiling at 3s
      if (phase.get(id)) return // resolved locally (button click) or already picked up
      const e = await getExecution(id)
      if (e && e.status !== 'pending_approval') {
        exec.set(id, e)
        if (e.status === 'completed') { phase.set(id, 'completed'); return }
        if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
        if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
        // 'approved' or 'running': someone said yes elsewhere — switch to
        // the same tracking the button click would have started.
        phase.set(id, 'running')
        void track(id)
        return
      }
      await new Promise((r) => setTimeout(r, 3000))
    }
  }

  $effect(() => {
    for (const a of approvals) {
      if (!phase.get(a.executionId)) {
        void watchExternal(a.executionId)
        void loadBlastRadius(a)
      }
    }
  })
</script>

{#each approvals as approval (approval.executionId)}
  {@const p = phase.get(approval.executionId)}
  {@const e = exec.get(approval.executionId)}
  {#if p === 'completed'}
    <div class="my-2 flex flex-col gap-1 rounded-lg border border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
      <div class="flex items-center gap-2">
        <CheckIcon class="size-4 shrink-0" />
        <span>Completed{e?.duration_ms ? ` in ${Math.round(e.duration_ms / 1000)}s` : ''} on {approval.target}.</span>
      </div>
      {#if outputText(e)}
        <pre class="max-h-32 overflow-y-auto whitespace-pre-wrap break-words pl-6 opacity-80">{outputText(e)}</pre>
      {/if}
    </div>
  {:else if p === 'failed'}
    <div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
      <div class="flex items-center gap-2">
        <XIcon class="size-4 shrink-0" />
        <span class="font-medium">Execution failed</span>
        <Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => decide(approval, 'approve')}>Retry</Button>
      </div>
      <pre class="whitespace-pre-wrap break-words pl-6 opacity-90">{errorText(e)}</pre>
    </div>
  {:else if p === 'denied'}
    <div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
      <XIcon class="size-4" /><span>Denied.</span>
    </div>
  {:else if p === 'running' || p === 'deciding'}
    {@const secs = elapsedSeconds(e)}
    <div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
      <div class="flex items-center gap-2">
        <Spinner class="size-4 shrink-0 text-warning" />
        <span>
          {#if p === 'deciding'}
            Submitting approval…
          {:else}
            Running on {approval.target}{secs !== null ? ` — ${fmtDuration(secs)} elapsed` : '…'}
          {/if}
        </span>
      </div>
      {#if p === 'running' && approval.command}
        <code class="ml-6 block truncate opacity-70">{approval.command}</code>
      {/if}
      {#if p === 'running'}
        <span class="ml-6 opacity-60">
          Execution <code>{approval.executionId.slice(0, 8)}</code> — long installs can take several minutes; this
          will resolve on its own (capped at 10 min) or you can check the Operations page for live output.
        </span>
      {/if}
    </div>
  {:else if p === 'stalled'}
    <div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
      <div class="flex items-center gap-2">
        <XIcon class="size-4 shrink-0" />
        <span class="font-medium">No update from the server in over 14 minutes.</span>
        <Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => { phase.delete(approval.executionId); void track(approval.executionId) }}>
          Check again
        </Button>
      </div>
      <span class="pl-6 opacity-90">
        The command itself is capped at 10 minutes server-side, so this is unusual — the API may be unreachable.
        Execution <code>{approval.executionId}</code>. Check the Operations page directly.
      </span>
    </div>
  {:else if approval.destructive}
    {@const affected = blastRadius.get(approval.executionId)}
    <div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2">
      <div class="flex items-center gap-2">
        <ShieldCheckIcon class="size-4 shrink-0 text-destructive" />
        <span class="flex-1 text-xs text-destructive">
          <strong>DESTRUCTIVE</strong> — {approval.action} on {approval.target}. Type
          "I confirm" in chat, or use the button.
        </span>
        <Button size="sm" variant="destructive" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
          <CheckIcon class="size-3" /><span class="ml-1">Confirm</span>
        </Button>
        <Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
          <XIcon class="size-3" /><span class="ml-1">Deny</span>
        </Button>
      </div>
      {#if approval.command}
        <code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code>
      {/if}
      {#if affected}
        <div class="ml-6 flex items-start gap-1.5 text-xs text-destructive/90">
          <NetworkIcon class="mt-0.5 size-3 shrink-0" />
          <span>Affects {affected.length} downstream: {affected.join(', ')}</span>
        </div>
      {/if}
    </div>
  {:else}
    {@const affected = blastRadius.get(approval.executionId)}
    <div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
      <div class="flex items-center gap-2">
        <ShieldCheckIcon class="size-4 shrink-0 text-warning" />
        <span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
        <Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
          <CheckIcon class="size-3" /><span class="ml-1">Approve</span>
        </Button>
        <Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
          <XIcon class="size-3" /><span class="ml-1">Deny</span>
        </Button>
      </div>
      {#if affected}
        <div class="ml-6 flex items-start gap-1.5 text-xs text-warning">
          <NetworkIcon class="mt-0.5 size-3 shrink-0" />
          <span>Affects {affected.length} downstream: {affected.join(', ')}</span>
        </div>
      {/if}
    </div>
  {/if}
{/each}
