<script setup lang="ts">
/**
 * Props-driven real-time build status composition. Subscribes to an
 * `EventSource` OR `WebSocket` (whichever is provided) and renders the
 * latest decoded `BuildStatusEvent`. The composition does NOT open or
 * close the underlying stream — the caller owns its lifecycle. The
 * optional `onUnsubscribe` callback fires on unmount for any extra
 * cleanup the caller wants.
 *
 *   <BuildStatus
 *     :initial="{ status: 'pending' }"
 *     :event-stream="es"
 *     :on-unsubscribe="() => es.close()"
 *   />
 */
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
import {
  Activity,
  AlertTriangle,
  Ban,
  CheckCircle2,
  ChevronDown,
  ChevronRight,
  Clock,
  Loader2,
  XCircle,
} from "@lucide/vue"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { cn } from "@/lib/utils"

export type BuildStatusValue =
  | "pending"
  | "running"
  | "success"
  | "failed"
  | "cancelled"

export interface BuildStatusEvent {
  status: BuildStatusValue
  startedAt?: string | Date
  finishedAt?: string | Date
  durationMs?: number
  commitSha?: string
  commitMessage?: string
  triggeredBy?: string
  logs?: string[]
}

interface MessageEventLike {
  data?: unknown
}
interface EventListenerTarget {
  addEventListener: (type: string, listener: (e: MessageEventLike) => void) => void
  removeEventListener: (
    type: string,
    listener: (e: MessageEventLike) => void,
  ) => void
}

export interface BuildStatusProps {
  /** Initial status to show before the stream emits. */
  initial?: BuildStatusEvent
  /** Optional EventSource to subscribe to (`message` events). */
  eventStream?: EventListenerTarget
  /** Optional WebSocket to subscribe to (`message` events). */
  socket?: EventListenerTarget
  /** Cleanup callback fired on unmount. The composition does NOT close
   *  the underlying stream itself — caller owns the lifecycle. */
  onUnsubscribe?: () => void
  /** When true, render `logs[]` as a collapsible monospace block. */
  showLogs?: boolean
  label?: string
  name?: string | null
  classList?: string[] | string
}

const props = withDefaults(defineProps<BuildStatusProps>(), {
  showLogs: true,
  label: "Build Status",
})

const current = ref<BuildStatusEvent>({
  status: props.initial?.status ?? "pending",
  startedAt: props.initial?.startedAt,
  finishedAt: props.initial?.finishedAt,
  durationMs: props.initial?.durationMs,
  commitSha: props.initial?.commitSha,
  commitMessage: props.initial?.commitMessage,
  triggeredBy: props.initial?.triggeredBy,
  logs: props.initial?.logs ? [...props.initial.logs] : [],
})

const logsExpanded = ref<boolean>(true)
const parseError = ref<string | null>(null)

watch(
  () => props.initial,
  (next) => {
    if (!next) {
      return
    }
    current.value = {
      status: next.status,
      startedAt: next.startedAt,
      finishedAt: next.finishedAt,
      durationMs: next.durationMs,
      commitSha: next.commitSha,
      commitMessage: next.commitMessage,
      triggeredBy: next.triggeredBy,
      logs: next.logs ? [...next.logs] : [],
    }
  },
  { deep: true },
)

function applyEvent(payload: unknown): void {
  if (!payload || typeof payload !== "object") {
    return
  }
  const next = payload as Partial<BuildStatusEvent>
  current.value = {
    status: (next.status as BuildStatusValue) ?? current.value.status,
    startedAt: next.startedAt ?? current.value.startedAt,
    finishedAt: next.finishedAt ?? current.value.finishedAt,
    durationMs:
      next.durationMs !== undefined ? next.durationMs : current.value.durationMs,
    commitSha: next.commitSha ?? current.value.commitSha,
    commitMessage: next.commitMessage ?? current.value.commitMessage,
    triggeredBy: next.triggeredBy ?? current.value.triggeredBy,
    logs: Array.isArray(next.logs) ? next.logs : current.value.logs,
  }
}

function handleMessage(e: MessageEventLike): void {
  const raw = e.data
  if (typeof raw === "string") {
    try {
      applyEvent(JSON.parse(raw))
      parseError.value = null
    } catch (err) {
      parseError.value = err instanceof Error ? err.message : String(err)
    }
  } else if (raw && typeof raw === "object") {
    applyEvent(raw)
  }
}

let attached: { target: EventListenerTarget; handler: (e: MessageEventLike) => void } | null = null

onMounted(() => {
  const target = props.eventStream ?? props.socket
  if (!target) {
    return
  }
  const handler = (e: MessageEventLike): void => {
    handleMessage(e)
  }
  target.addEventListener("message", handler)
  attached = { target, handler }
})

onBeforeUnmount(() => {
  if (attached) {
    attached.target.removeEventListener("message", attached.handler)
    attached = null
  }
  if (props.onUnsubscribe) {
    props.onUnsubscribe()
  }
})

// -------- visual helpers ------------------------------------------------

const statusBadgeVariant = computed<
  "secondary" | "info" | "success" | "destructive" | "warning"
>(() => {
  switch (current.value.status) {
    case "running":
      return "info"
    case "success":
      return "success"
    case "failed":
      return "destructive"
    case "cancelled":
      return "warning"
    case "pending":
    default:
      return "secondary"
  }
})

const statusIcon = computed(() => {
  switch (current.value.status) {
    case "running":
      return Loader2
    case "success":
      return CheckCircle2
    case "failed":
      return XCircle
    case "cancelled":
      return Ban
    case "pending":
    default:
      return Clock
  }
})

function formatDuration(value: number | undefined): string {
  if (value === undefined || Number.isNaN(value)) {
    return ""
  }
  const sec = Math.round(value / 1000)
  if (sec < 60) {
    return `${sec}s`
  }
  const m = Math.floor(sec / 60)
  const s = sec % 60
  return `${m}m ${s}s`
}

const shortSha = computed<string>(() => {
  const sha = current.value.commitSha
  if (!sha) {
    return ""
  }
  return sha.length > 7 ? sha.slice(0, 7) : sha
})

const durationLabel = computed<string>(() =>
  formatDuration(current.value.durationMs),
)

const isRunning = computed<boolean>(() => current.value.status === "running")

defineExpose({ applyEvent })
</script>

<template>
  <Card
    :class="cn(props.classList)"
    :data-element-name="props.name ?? undefined"
    data-testid="build-status"
  >
    <CardHeader class="flex flex-col gap-2 pb-3">
      <div class="flex items-center justify-between gap-2">
        <CardTitle class="flex items-center gap-2 text-base">
          <Activity class="size-5 text-muted-foreground" />
          <span>{{ props.label }}</span>
        </CardTitle>
        <Badge
          :variant="statusBadgeVariant"
          size="default"
          class="capitalize"
          data-testid="build-status-badge"
        >
          <component
            :is="statusIcon"
            :class="
              cn(
                'size-3.5',
                isRunning ? 'animate-spin' : '',
              )
            "
            :data-testid="
              isRunning
                ? 'build-status-spinner'
                : 'build-status-icon'
            "
          />
          <span>{{ current.status }}</span>
        </Badge>
      </div>
      <div
        class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
      >
        <span
          v-if="shortSha"
          class="font-mono"
          data-testid="build-status-sha"
        >
          {{ shortSha }}
        </span>
        <span
          v-if="current.commitMessage"
          class="truncate"
          data-testid="build-status-commit-message"
        >
          {{ current.commitMessage }}
        </span>
        <span
          v-if="current.triggeredBy"
          data-testid="build-status-triggered-by"
        >
          by {{ current.triggeredBy }}
        </span>
        <span
          v-if="durationLabel"
          class="flex items-center gap-1"
          data-testid="build-status-duration"
        >
          <Clock class="size-3" />
          {{ durationLabel }}
        </span>
      </div>
      <p
        v-if="parseError"
        class="text-xs text-destructive"
        role="alert"
        data-testid="build-status-parse-error"
      >
        <AlertTriangle class="inline size-3" />
        {{ parseError }}
      </p>
    </CardHeader>
    <CardContent
      v-if="props.showLogs && current.logs && current.logs.length > 0"
      class="flex flex-col gap-2 pt-0"
    >
      <Button
        type="button"
        variant="ghost"
        size="sm"
        class="w-fit -ml-2 text-xs"
        data-testid="build-status-logs-toggle"
        @click="logsExpanded = !logsExpanded"
      >
        <component
          :is="logsExpanded ? ChevronDown : ChevronRight"
          class="size-4"
        />
        <span>{{ logsExpanded ? "Hide logs" : "Show logs" }} ({{ current.logs.length }})</span>
      </Button>
      <pre
        v-if="logsExpanded"
        class="max-h-80 overflow-auto rounded-md bg-muted p-3 font-mono text-xs leading-snug"
        data-testid="build-status-logs"
      ><code><template
          v-for="(line, i) in current.logs"
          :key="i"
        >{{ line }}<br></template></code></pre>
    </CardContent>
  </Card>
</template>
