// JobqGraph.jsx — <JobqGraph>, a Preact component rendering any
// hive_jobq graph from the wire shape GET /api/jobq/graph serves (any
// endpoint serving `Vec<hive_jobq_wire::GraphNode>` works — see
// hive-jobq-wire's README). Renders an indented state tree:
// `payload.label` verbatim, `payload.data` as a generic key/value list.
// Light DOM, shared by the dashboard and swarm-ui — both esbuild
// configs run `jsx: 'automatic', jsxImportSource: 'preact'`, so this
// file is real JSX in either build, not hand-written `h()` calls.
//
// `cancellable` adds a per-node cancel button calling `onCancel(id)`
// directly (a plain prop). `onUpdate(nodes)` fires after every fetch,
// for a host needing the raw list (a count badge, a live-log panel)
// without its own parallel fetch.
//
// Two ways to use this: JSX (swarm-ui, or any future dashboard page
// that renders it directly) — `<JobqGraph endpoint="..." cancellable
// onCancel={...} onUpdate={...} />`, a normal component. Or imperative
// mount (dashboard/src/builds.js, which stays plain `.js` — a `.jsx`
// call site still needs a JSX-aware file, mounting doesn't) —
// `mountJobqGraph(container, props)` returns a `{ refresh(),
// update(props) }` handle.
//
// Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
// page/component CSS file rather than imported here. This one stays
// necessary regardless of the JSX question above: dashboard bundles
// every page entry (including this component, pulled in transitively)
// through one esbuild call whose `.css` loader is `text` — a handful of
// shadow-DOM components (modal.js, hive-btn.js) need their CSS as a
// literal string to inject into a shadow root, and esbuild's loader map
// is global per call, not per-module. Importing `.css` here would
// silently pick up that `text` loader too and bind a useless string
// instead of applying styles, so this file imports no CSS at all;
// each consumer's own page/component CSS `@import`s it instead.

import { h, render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';

const STATE_GLYPH = {
  Pending: '⏸',
  Running: '▶',
  Finishing: '◐',
  Done: '✔',
  Failed: '✖',
  Cancelled: '⊘',
  Skipped: '·',
};

// Declaration order doubles as render order for the filter checkboxes —
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in
// the same lifecycle order the rollup endpoint counts in.
const ALL_STATES = Object.keys(STATE_GLYPH);

// Product call: "default selection filters out skipped and done."
const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']);

// Non-terminal states a cancel button makes sense on. Finishing is
// included — "own logic done, children still running" is still a subtree
// worth stopping early.
const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);

// Build a parent/child tree from the flat wire array. `parent` (structural
// grouping) defines tree shape. Sibling order follows array order, which
// is already root-then-subtree per root per `GraphWire::wire_snapshot`'s
// own doc contract — no reordering; a dependency is named in text (see
// `_waitsOn` below), not implied by render position.
//
// Also resolves each node's `Node`-kind deps to the referenced node's own
// label, once, so rendering doesn't need a second lookup pass. Looked up
// globally (`byId`, every node in this snapshot), not scoped to siblings —
// a dep is expected to always name a sibling, but resolving globally means
// a label still shows correctly even if that expectation is ever wrong,
// instead of silently dropping the edge. A dep naming an id outside this
// snapshot (a filtered view) or a `Resource`-kind dep has nothing to point
// at and is simply not listed.
function buildTree(nodes) {
  const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
  const roots = [];
  for (const n of byId.values()) {
    const p = n.parent != null ? byId.get(n.parent) : null;
    if (p) p._children.push(n);
    else roots.push(n);
  }
  for (const n of byId.values()) {
    n._waitsOn = (n.deps || [])
      .filter((d) => d.kind === 'Node')
      .map((d) => byId.get(d.id))
      .filter(Boolean)
      .map((dep) => dep.payload.label);
  }
  return roots;
}

// `payload.data` is an opaque JSON value from the host's `WireNode::data`
// — render it as a generic key/value list when it's a plain object (the
// only shape a host is expected to send; anything else falls back to a
// single stringified row rather than silently dropping it).
function DataList({ data }) {
  if (data == null) return null;
  const isPlainObject = typeof data === 'object' && !Array.isArray(data);
  const entries = isPlainObject ? Object.entries(data) : [['data', data]];
  if (!entries.length) return null;
  return (
    <dl class="jg-data">
      {entries.map(([k, v]) => (
        <>
          <dt key={k + '-dt'}>{k}</dt>
          <dd key={k + '-dd'}>{typeof v === 'string' ? v : JSON.stringify(v)}</dd>
        </>
      ))}
    </dl>
  );
}

function NodeView({ n, cancellable, onCancel }) {
  const glyph = STATE_GLYPH[n.state] || '?';
  const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
  return (
    <div class="jg-node">
      <div class="jg-row">
        <span class={'jg-state jg-state-' + n.state.toLowerCase()}
          title={n.state + (n.error ? ' — ' + n.error : '')}>
          {glyph}
        </span>
        {' '}
        <span class="jg-label">{n.payload.label}</span>
        {showCancel && (
          <button type="button" class="jg-cancel-btn" title={'cancel ' + n.payload.label}
            onClick={() => onCancel && onCancel(n.id)}>
            ✕
          </button>
        )}
      </div>
      {n._waitsOn && n._waitsOn.length > 0 && (
        <div class="jg-waits-on">waits on: {n._waitsOn.join(', ')}</div>
      )}
      <DataList data={n.payload.data} />
      {n.error && <pre class="jg-error">{n.error}</pre>}
      {n._children.map((child) => (
        <NodeView key={child.id} n={child} cancellable={cancellable} onCancel={onCancel} />
      ))}
    </div>
  );
}

function FilterBar({ selectedStates, onToggle }) {
  return (
    <div class="jg-filter">
      {ALL_STATES.map((state) => {
        const id = 'jg-filter-' + state.toLowerCase();
        return (
          <label key={state} for={id} class={'jg-filter-label jg-state-' + state.toLowerCase()}>
            <input type="checkbox" id={id} checked={selectedStates.has(state)}
              onChange={() => onToggle(state)} />
            {' '}{STATE_GLYPH[state] + ' ' + state}
          </label>
        );
      })}
    </div>
  );
}

// `endpoint` plus the current filter selection as a `states=` query
// param — omitted entirely when every state is checked, so the
// unfiltered default case sends the exact same request as before this
// filter existed.
function fetchUrl(endpoint, selectedStates) {
  if (!endpoint) return null;
  if (selectedStates.size >= ALL_STATES.length) return endpoint;
  const url = new URL(endpoint, window.location.origin);
  url.searchParams.set('states', Array.from(selectedStates).join(','));
  return url.pathname + url.search;
}

// `refreshToken` is not read anywhere in the body — its only job is to
// change identity so the effect below re-runs, giving a host (or
// `mountJobqGraph`) an explicit "refetch now" lever without an
// imperative ref into this component.
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }) {
  const [selectedStates, setSelectedStates] = useState(
    () => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
  );
  const [nodes, setNodes] = useState(null); // null = loading, [] = empty-but-loaded
  const [error, setError] = useState(null);

  const toggleState = useCallback((state) => {
    setSelectedStates((prev) => {
      const next = new Set(prev);
      if (next.has(state)) next.delete(state); else next.add(state);
      return next;
    });
  }, []);

  useEffect(() => {
    const url = fetchUrl(endpoint, selectedStates);
    if (!url) return undefined;
    let cancelled = false;
    (async () => {
      try {
        const r = await fetch(url);
        if (!r.ok) throw new Error('http ' + r.status);
        const data = await r.json();
        if (cancelled) return;
        setNodes(data);
        setError(null);
        if (onUpdate) onUpdate(data);
      } catch (err) {
        if (cancelled) return;
        setError(String(err));
      }
    })();
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedStates is a Set;
    // its *contents* are what should retrigger the fetch, not its identity, and the
    // string form below already changes identity exactly when contents do.
  }, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]);

  return (
    <div class="jg-root">
      <FilterBar selectedStates={selectedStates} onToggle={toggleState} />
      <div class="jg-body">
        {error ? (
          <p class="jg-error-msg">fetch failed: {error}</p>
        ) : nodes === null ? (
          <p class="jg-empty">loading…</p>
        ) : !nodes.length ? (
          <p class="jg-empty">empty</p>
        ) : (
          buildTree(nodes).map((root) => (
            <NodeView key={root.id} n={root} cancellable={cancellable} onCancel={onCancel} />
          ))
        )}
      </div>
    </div>
  );
}

// Imperative mount helper for a call site that isn't itself a JSX file
// (dashboard/src/builds.js — plain `.js`, no per-file JSX pragma to
// write `<JobqGraph .../>` inline). Returns a handle: `.refresh()`
// (re-fetch with the current props) and `.update(props)` (merge new
// props — e.g. a different `endpoint` — and re-render).
export function mountJobqGraph(container, initialProps) {
  let props = initialProps;
  let token = 0;
  const draw = () => render(h(JobqGraph, { ...props, refreshToken: token }), container);
  draw();
  return {
    refresh() { token += 1; draw(); },
    update(next) { props = { ...props, ...next }; draw(); },
  };
}
