<script setup lang="ts">
/**
 * Editor (C1) — the Vue Flow canvas and chrome.
 *
 * The Model store is the source of truth; `nodes`/`edges` here are a derived
 * projection bound one-way into Vue Flow (ADR-0002). Vue Flow owns interaction
 * (pan/zoom/drag/select) on its internal copy; we translate the gestures we care
 * about back into store actions:
 *   - drag start → take an undo restore point
 *   - drag stop  → commit final positions
 *   - Delete/Backspace → remove selected nodes (Vue Flow's own delete is off, so
 *     it can't delete from its copy behind the Model's back)
 * After each store mutation the projection recomputes and Vue Flow reconciles by
 * id, so positions never fight.
 */
import { Background } from "@vue-flow/background"
import { Controls } from "@vue-flow/controls"
import {
  type Connection,
  ErrorCode,
  isErrorOfType,
  type OnConnectStartParams,
  useVueFlow,
  VueFlow,
  type XYPosition,
} from "@vue-flow/core"
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from "vue"
import { useAutosave } from "@/composables/useAutosave"
import { usePlayback } from "@/composables/usePlayback"
import { parseModel, serializeModel } from "@/model/io"
import { project } from "@/model/projection"
import type { Sample } from "@/model/samples"
import { canConnect } from "@/model/validation"
import { useModelStore } from "@/store/model"
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
import GlossPanel from "./GlossPanel.vue"
import Inspector from "./Inspector.vue"
import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue"
import ResultsPanel from "./ResultsPanel.vue"
import SampleBrowser from "./SampleBrowser.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import PipeEdge from "./edges/PipeEdge.vue"
import CloudNode from "./nodes/CloudNode.vue"
import ConverterNode from "./nodes/ConverterNode.vue"
import FlowNode from "./nodes/FlowNode.vue"
import StockNode from "./nodes/StockNode.vue"

const store = useModelStore()

const graph = computed(() => project(store.model))
const nodes = computed(() => graph.value.nodes)
const edges = computed(() => graph.value.edges)

// The simulation results panel (phase 2). Toggled from the header; the panel runs
// the Model and recomputes reactively while open.
const showResults = ref(false)

// The sample-browser modal (opened from the header "Samples" button).
const browserOpen = ref(false)

// The playback clock that advances the playhead while a run is playing (mounted
// once here; the simulation store holds the state it drives).
usePlayback()

// Explicit shared id: useVueFlow() runs here in the parent setup, before
// <VueFlow> mounts. Pinning both to the same id guarantees they resolve to one
// store instance, so the event hooks below actually fire.
const {
  onNodeDragStart,
  onNodeDragStop,
  onConnect,
  onConnectStart,
  onConnectEnd,
  onError,
  onNodesInitialized,
  getSelectedNodes,
  getSelectedEdges,
  viewport,
  vueFlowRef,
  dimensions,
  fitView,
} = useVueFlow("meadows")

// Fit the view after a *load* (sample/import/restore), once the freshly projected
// nodes have been measured. Vue Flow measures node dimensions a frame after they
// mount, so calling fitView straight after setModel fits to 0×0 boxes and the model
// lands jammed in the top-left; onNodesInitialized fires post-measure, so framing
// is correct. A one-shot flag keeps it scoped to loads (not every re-init).
let fitAfterInit = false
onNodesInitialized(() => {
  if (!fitAfterInit) return
  fitAfterInit = false
  fitView({ padding: 0.2 })
})

// Docking the results panel (Simulate) shrinks the canvas; closing it gives the
// space back. Re-fit either way so the whole diagram stays framed in whatever
// height remains. Vue Flow re-measures the pane a frame after the panel
// mounts/unmounts and republishes the size via `dimensions`; fitting on *that*
// signal (not straight after the toggle) frames against the settled size, so the
// diagram never lands jammed against the new edge. The flag scopes the fit to
// panel toggles, leaving ordinary window resizes to Vue Flow's own handling.
let fitOnResize = false
watch(showResults, () => {
  fitOnResize = true
})
watch(
  () => [dimensions.value.width, dimensions.value.height],
  () => {
    if (!fitOnResize) return
    fitOnResize = false
    fitView({ padding: 0.2 })
  },
)

// Restore the last document on mount and persist every change (F7). Arm the same
// post-measure fit a load uses; fitting synchronously in onRestore would frame
// against unmeasured 0×0 nodes, landing the restored model jammed top-left.
useAutosave({
  onRestore: () => {
    fitAfterInit = true
  },
})

onNodeDragStart(() => store.beginInteraction())
onNodeDragStop(({ nodes: dragged }) => {
  for (const node of dragged) store.moveNode(node.id, node.position)
})

// Vue Flow measures the pane synchronously in its own onMounted, before the
// browser has flushed first layout, so the initial read is 0×0 and it emits a
// benign MISSING_VIEWPORT_DIMENSIONS error (it falls back to 500×500, then its
// ResizeObserver corrects to the real size next frame). Swallow only that one;
// registering any handler suppresses Vue Flow's default console.warn, so log
// every other error to keep genuine problems visible.
onError((err) => {
  if (isErrorOfType(err, ErrorCode.MISSING_VIEWPORT_DIMENSIONS)) return
  console.warn(err.message)
})

// --- Connecting (F2) ---
// Refuse invalid drops mid-gesture so the structure stays valid (F4, "guide,
// don't nag"). The same guard backs store.connect as defence in depth.
//
// Vue Flow runs this guard for BOTH live gestures and the edges we project. The
// projected pipe/link edges arrive as full edges (with an `id`) and are valid by
// construction — the store guards every mutation — whereas a live gesture is a
// handle-less Connection. We must only guard the gesture: the derived pipe-out
// edge is valve→stock, which is invalid *as a gesture* but a real rendered edge,
// so validating projected edges would drop every pipe-out (Vue Flow EDGE_INVALID).
function isValidConnection(connection: Connection): boolean {
  if ("id" in connection) return true
  const ok = canConnect(store.model, connection.source, connection.target)
  // Remember the latest *refused* close-the-loop attempt so the release handler
  // can explain it. Overwrite on every candidate (clearing when valid) so this
  // reflects the handle we actually let go on, not one we merely passed over.
  pendingLoopHint = ok ? null : loopClosureHint(connection.source, connection.target)
  return ok
}

/**
 * The message for dragging a Flow back onto a Stock it already touches — the
 * "close the loop by hand" gesture. The drop is refused (an Information Link
 * never targets a Stock; Stocks change only via Flows, ADR-0001), but the loop
 * is already there: the Flow's own pipe *is* the link back. Naming that turns a
 * dead-end gesture into the lesson (F4). Returns null for any other refusal,
 * which stays silent.
 */
function loopClosureHint(sourceId: string, targetId: string): string | null {
  const flow = store.model.nodes.find((node) => node.id === sourceId)
  if (flow?.kind !== "flow") return null
  const stock = store.model.nodes.find((node) => node.id === targetId)
  if (stock?.kind !== "stock") return null
  if (flow.source !== stock.id && flow.target !== stock.id) return null
  const direction = flow.source === stock.id ? "out of" : "into"
  return `${flow.name} already flows ${direction} ${stock.name} — that pipe is the link back, so the loop is already there.`
}

// A transient, self-dismissing teaching hint shown over the canvas (F4).
const hint = ref<string | null>(null)
let hintTimer: ReturnType<typeof setTimeout> | undefined
function showHint(message: string): void {
  hint.value = message
  clearTimeout(hintTimer)
  hintTimer = setTimeout(() => (hint.value = null), 6000)
}

// Remember the drag's source so releasing on empty canvas can open onto a Cloud.
let connectSource: string | null = null
let connectionMade = false
// Set by isValidConnection when the live gesture is a refused loop-closure.
let pendingLoopHint: string | null = null

onConnectStart((params: OnConnectStartParams) => {
  connectSource = params.handleType === "source" ? (params.nodeId ?? null) : null
  connectionMade = false
  pendingLoopHint = null
})

onConnect((connection) => {
  connectionMade = true
  store.connect(connection.source, connection.target)
})

onConnectEnd((event) => {
  if (!connectionMade) {
    // A refused loop-closure and an empty-canvas open are mutually exclusive
    // (the former needs a Flow source, the latter a Stock source), so prefer the
    // explanation when we have one.
    if (pendingLoopHint) showHint(pendingLoopHint)
    else if (connectSource && event) {
      const point = pointerPosition(event)
      if (point) store.connectToNewCloud(connectSource, screenToFlow(point.x, point.y))
    }
  }
  connectSource = null
  pendingLoopHint = null
})

/** clientX/clientY from a mouse or touch end event. */
function pointerPosition(event: MouseEvent | TouchEvent): { x: number; y: number } | null {
  if ("clientX" in event) return { x: event.clientX, y: event.clientY }
  const touch = event.changedTouches[0]
  return touch ? { x: touch.clientX, y: touch.clientY } : null
}

/** Convert a screen point (clientX/clientY) to graph coordinates. */
function screenToFlow(clientX: number, clientY: number): XYPosition {
  const rect = vueFlowRef.value?.getBoundingClientRect()
  const { x, y, zoom } = viewport.value
  return {
    x: (clientX - (rect?.left ?? 0) - x) / zoom,
    y: (clientY - (rect?.top ?? 0) - y) / zoom,
  }
}

/** Drop a node so its top-left lands roughly centred on the given graph point. */
function placeNode(kind: PlaceableKind, at: XYPosition): void {
  const position = { x: at.x - 48, y: at.y - 24 }
  if (kind === "stock") store.addStock(position)
  else store.addConverter(position)
}

/** Click-to-add: drop at the visible pane centre, cascaded so clicks don't stack. */
function addNode(kind: PlaceableKind): void {
  const rect = vueFlowRef.value?.getBoundingClientRect()
  const centre = screenToFlow(
    (rect?.left ?? 0) + (rect?.width ?? 800) / 2,
    (rect?.top ?? 0) + (rect?.height ?? 600) / 2,
  )
  const step = (store.model.nodes.length % 8) * 22
  placeNode(kind, { x: centre.x + step, y: centre.y + step })
}

/** Drag-to-add: drop where the chip is released. */
function onDrop(event: DragEvent): void {
  event.preventDefault()
  const kind = event.dataTransfer?.getData(NODE_DND_MIME)
  if (kind !== "stock" && kind !== "converter") return
  placeNode(kind, screenToFlow(event.clientX, event.clientY))
}

function onDragOver(event: DragEvent): void {
  event.preventDefault()
  if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"
}

/**
 * Load a sample Model (G4, the instructive goal). `setModel` replaces the whole
 * document and clears undo history, so guard real work behind a confirm — but
 * stay frictionless on the empty starting canvas. Fit the view once the
 * projection has re-derived so the loaded model lands framed.
 */
function loadSample(sample: Sample): void {
  if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
    // Cancelled: leave the model untouched and the browser open to pick again.
    return
  }
  fitAfterInit = true
  store.setModel(sample.build())
  browserOpen.value = false
}

const fileInput = useTemplateRef<HTMLInputElement>("fileInput")

/** A filesystem-safe stem from the model name, e.g. "Coffee cooling" → "coffee-cooling". */
function fileStem(name: string): string {
  const slug = name
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
  return slug || "model"
}

/** Export the current Model as versioned JSON download (F8). */
function exportModel(): void {
  const blob = new Blob([serializeModel(store.model)], { type: "application/json" })
  const url = URL.createObjectURL(blob)
  const anchor = document.createElement("a")
  anchor.href = url
  anchor.download = `${fileStem(store.model.name)}.json`
  anchor.click()
  URL.revokeObjectURL(url)
}

/** Import a Model from a JSON file: validate, confirm a destructive replace, load (F8). */
async function onImportFile(event: Event): Promise<void> {
  const input = event.target as HTMLInputElement
  const file = input.files?.[0]
  // Reset first so picking the same file again still fires `change`.
  input.value = ""
  if (!file) return

  const result = parseModel(await file.text())
  if (!result.ok) {
    window.alert(`Couldn't import “${file.name}”: ${result.error}`)
    return
  }
  // Importing replaces the document and clears undo (T6 / F10×F7), so guard real work.
  if (
    store.nodeCount > 0 &&
    !window.confirm(`Replace the current model with “${result.model.name}”?`)
  ) {
    return
  }
  fitAfterInit = true
  store.setModel(result.model)
}

function isTextEntry(target: EventTarget | null): boolean {
  const el = target as HTMLElement | null
  return el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable === true
}

function onKeydown(event: KeyboardEvent): void {
  if (isTextEntry(event.target)) return

  const mod = event.metaKey || event.ctrlKey
  if (mod && event.key.toLowerCase() === "z") {
    event.preventDefault()
    if (event.shiftKey) store.redo()
    else store.undo()
    return
  }

  if (event.key === "Delete" || event.key === "Backspace") {
    const nodeIds = getSelectedNodes.value.map((node) => node.id)
    // Edges are projected, not stored: a "pipe" edge stands in for its Flow node
    // (id is `${flowId}::in|out`), an "info" edge is the Information Link itself.
    const flowIds = new Set<string>()
    const linkIds = new Set<string>()
    for (const edge of getSelectedEdges.value) {
      if (edge.data?.kind === "pipe") flowIds.add(edge.id.split("::")[0])
      else if (edge.data?.kind === "info") linkIds.add(edge.id)
    }

    if (nodeIds.length === 0 && flowIds.size === 0 && linkIds.size === 0) return
    event.preventDefault()
    for (const id of nodeIds) store.removeNode(id)
    for (const id of flowIds) store.removeNode(id)
    for (const id of linkIds) store.removeInfoLink(id)
  }
}

onMounted(() => window.addEventListener("keydown", onKeydown))
onBeforeUnmount(() => {
  window.removeEventListener("keydown", onKeydown)
  clearTimeout(hintTimer)
})
</script>

<template>
  <div class="flex h-screen flex-col bg-base-200">
    <header class="flex items-center gap-3 border-b border-base-300 bg-base-100 px-4 py-2">
      <!-- Inlined favicon so the logo strokes with the theme primary (currentColor
           via text-primary); an external <img> can't read the theme var. -->
      <svg
        class="size-6 text-primary"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="M14 20a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
        <path d="M14 4a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
        <path d="M6 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
        <path d="M22 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
        <path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
        <path d="M6 12h4" />
        <path d="M14 12h4" />
        <path d="M12 6v4" />
        <path d="M12 14v4" />
      </svg>
      <h1 class="text-lg font-semibold">meadows</h1>
      <span class="text-sm text-base-content/50">
        {{ store.nodeCount }} {{ store.nodeCount === 1 ? "element" : "elements" }}
      </span>
      <div class="ml-auto flex items-center gap-1">
        <button class="btn btn-ghost btn-sm" @click="browserOpen = true">Samples</button>
        <button
          class="btn btn-primary btn-sm"
          :class="{ 'btn-active': showResults }"
          @click="showResults = !showResults"
        >
          Simulate
        </button>
        <button class="btn btn-ghost btn-sm" @click="exportModel">Export</button>
        <button class="btn btn-ghost btn-sm" @click="fileInput?.click()">Import</button>
        <input
          ref="fileInput"
          type="file"
          accept="application/json,.json"
          class="hidden"
          @change="onImportFile"
        />
        <button class="btn btn-ghost btn-sm" :disabled="!store.canUndo" @click="store.undo()">
          Undo
        </button>
        <button class="btn btn-ghost btn-sm" :disabled="!store.canRedo" @click="store.redo()">
          Redo
        </button>
      </div>
    </header>

    <div class="relative min-h-0 flex-1" @drop="onDrop" @dragover="onDragOver">
      <VueFlow
        id="meadows"
        :nodes="nodes"
        :edges="edges"
        :delete-key-code="null"
        :is-valid-connection="isValidConnection"
        :connection-radius="30"
        :min-zoom="0.2"
        :max-zoom="4"
        :snap-to-grid="true"
        :snap-grid="[20, 20]"
        class="size-full"
      >
        <Background :gap="20" pattern-color="#d1d5db" />
        <Controls position="bottom-left" :show-interactive="false" />

        <template #node-stock="nodeProps">
          <StockNode v-bind="nodeProps" />
        </template>
        <template #node-converter="nodeProps">
          <ConverterNode v-bind="nodeProps" />
        </template>
        <template #node-flow="nodeProps">
          <FlowNode v-bind="nodeProps" />
        </template>
        <template #node-cloud="nodeProps">
          <CloudNode v-bind="nodeProps" />
        </template>

        <template #edge-pipe="edgeProps">
          <PipeEdge v-bind="edgeProps" />
        </template>
        <template #edge-info="edgeProps">
          <InfoLinkEdge v-bind="edgeProps" />
        </template>
      </VueFlow>

      <!-- Chrome lives at this level, above the badge overlay: Vue Flow's pane is a
           z-index:0 stacking context, so a Panel inside it would be painted under
           the screen-space R/B badges (which sit on the canvas, over loop centres). -->
      <Palette class="absolute top-3 left-3 z-20" @add="addNode" />
      <LoopOverlay />
      <GlossPanel />
      <Inspector />

      <!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
           own Stock to "close the loop". Click to dismiss early; z-30 keeps it above
           the palette and badge overlay. role=status announces it politely. -->
      <div v-if="hint" class="toast toast-center toast-bottom z-30">
        <div role="status" class="alert alert-info max-w-sm text-left shadow-lg">
          <span>{{ hint }}</span>
          <button
            type="button"
            class="btn btn-circle btn-ghost btn-xs"
            aria-label="Dismiss"
            @click="hint = null"
          >
            ✕
          </button>
        </div>
      </div>
    </div>

    <!-- Docked beneath the canvas (not floating over it): opening Simulate shrinks
         the canvas to the space above, where the diagram re-fits (see the
         `dimensions` watcher in <script>). -->
    <ResultsPanel v-if="showResults" @close="showResults = false" />

    <SampleBrowser v-model:open="browserOpen" @select="loadSample" />
  </div>
</template>
