#!/bin/bash
# UserPromptSubmit hook: detect image input (pasted / local path / URL) and arm the
# TASK-008 write gate by writing pending-<sha>.json markers via extract-image.mjs.
#
# FAILS OPEN, unconditionally: this hook must never block the user's prompt. Any
# failure (malformed stdin, missing extractor, node crash) degrades to "no hint,
# no markers" and exit 0. This is the deliberate opposite of the TASK-008 gate,
# which fails closed on Edit/Write.
#
# Detection only — never decodes an image, never writes an image file. Marker
# writing is delegated entirely to `extract-image.mjs --mark-pending`; this hook
# never computes a sha itself (PLAN §3.1).
#
# Runs on EVERY prompt, 8s budget: one shell-out to extract-image.mjs
# --detect --mark-pending --json, no decoding.

INPUT=$(cat)
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"

INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { pathToFileURL } = require('url');

(async () => {
  const payload = (() => {
    try {
      const parsed = JSON.parse(process.env.INPUT || '');
      return parsed && typeof parsed === 'object' ? parsed : {};
    } catch {
      return {};
    }
  })();
  const projectRoot = process.env.PROJECT_ROOT;

  function extractPromptText(p) {
    const candidates = [p?.prompt, p?.user_prompt, p?.text];
    for (const candidate of candidates) {
      if (typeof candidate === 'string' && candidate.trim()) return candidate;
    }
    return '';
  }

  const promptText = extractPromptText(payload);

  // Tri-state, same contract as vision-gate.sh: true | false | null(unknown).
  async function detectUnicModeSafe() {
    try {
      const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
      if (!fs.existsSync(gatewayPath)) return null;
      const mod = await import(pathToFileURL(gatewayPath).href);
      if (typeof mod.detectUnicGateway !== 'function') return null;
      return !!mod.detectUnicGateway({ rootDir: projectRoot })?.unicMode;
    } catch {
      return null;
    }
  }

  const unicMode = await detectUnicModeSafe();

  // Off the UNIC gateway Claude reads images natively, so there is nothing to route and
  // nothing to gate. Exit before marking anything: an armed marker that the gate will
  // stand down on anyway is pure noise, and the advice text used to demand an analyst run
  // whose receipt could never be accepted. Only a POSITIVE false short-circuits; null
  // (detection failed) still routes, matching the gate's fail-safe direction.
  //
  // Cheaper than the old order, too: this replaces an execFileSync node spawn per prompt
  // with one local import on the overwhelmingly common off-gateway path.
  if (unicMode === false) {
    process.exit(0);
  }

  // URL images are checked first and stripped out so the local-path regex never
  // re-matches the tail of a URL (case c is a hint-only lane — no download here).
  const URL_IMAGE_RE = /https?:\/\/\S+\.(?:png|jpe?g|gif|webp)\b/gi;
  const LOCAL_PATH_RE = /\S+\.(?:png|jpe?g|gif|webp|bmp)\b/gi;

  const urlMatches = promptText.match(URL_IMAGE_RE) || [];
  const textWithoutUrls = promptText.replace(URL_IMAGE_RE, ' ');
  const localMatches = textWithoutUrls.match(LOCAL_PATH_RE) || [];

  // Shell out to the single owner of image detection + marker writing. Runs on every
  // prompt (cheap: --detect --mark-pending, never decodes). All three input forms are
  // armed here, not just pasted images: a path or URL that only produced hint text would
  // leave Edit/Write ungated, which is exactly the "analyse blind" case this cycle exists
  // to prevent. --ref hashes a local file's bytes / a URL string inside the extractor.
  let markedPasted = 0;
  let markedPath = 0;
  let markedUrl = 0;
  const extractorPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'extract-image.mjs');
  if (fs.existsSync(extractorPath)) {
    const args = [extractorPath, '--detect', '--mark-pending', '--json'];
    if (typeof payload?.transcript_path === 'string' && payload.transcript_path.trim()) {
      args.push('--session', payload.transcript_path.trim());
    }
    if (typeof payload?.session_id === 'string' && payload.session_id.trim()) {
      args.push('--session-id', payload.session_id.trim());
    }
    for (const ref of [...localMatches, ...urlMatches]) {
      args.push('--ref', ref);
    }
    try {
      const out = execFileSync('node', args, {
        cwd: projectRoot,
        encoding: 'utf8',
        timeout: 6000,
      });
      const parsed = JSON.parse(out);
      const images = Array.isArray(parsed?.images) ? parsed.images : [];
      // Ref markers carry a `source`; pasted ones do not. Counting this way stays correct
      // when a named path does not exist on disk and the extractor drops it.
      markedPasted = images.filter((img) => !img?.source).length;
      markedPath = images.filter((img) => img?.source === 'path').length;
      markedUrl = images.filter((img) => img?.source === 'url').length;
    } catch (err) {
      process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})\n`);
    }
  }

  // `cases` reflects what the PROMPT mentioned; `armed` reflects what actually got a
  // pending marker. They differ when a named path does not resolve from the project root
  // — still worth a hint, but the gate is not armed and must not be claimed to be.
  const cases = [];
  if (markedPasted > 0) cases.push('pasted image');
  if (localMatches.length > 0) cases.push('local file path');
  if (urlMatches.length > 0) cases.push('image URL');
  const armed = markedPasted + markedPath + markedUrl;

  if (cases.length === 0) {
    process.exit(0);
    return;
  }

  // Only unicMode true or null reach here; off-gateway already exited. Both remaining
  // cases get the same strict `unic-vision` remedy, so there is no fallback-model branch
  // to advertise. There must never be one: the analyst self-reports the model it really
  // ran on, so telling it to claim some other ID is asking it to falsify the receipt —
  // which the gate is entitled to reject, and which defeats the point of having a gate.
  let unicNote = '';
  if (unicMode === true) {
    try {
      const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
      const mod = await import(pathToFileURL(gatewayPath).href);
      const result = mod.detectUnicGateway({ rootDir: projectRoot });
      if (result?.visionModel) {
        unicNote = `  (UNIC gateway active — ${result.visionModel} routes through it.)`;
      }
    } catch {
      unicNote = '';
    }
  }

  const reasonLine = armed > 0
    ? 'unic-code / unic-smart cannot read images on this gateway, and Edit/Write is now GATED\nuntil a vision analysis exists. Do this before anything else:'
    : 'unic-code / unic-smart cannot read images on this gateway. Do this before anything else:';
  const agentLine = '  2. Agent(subagent_type: "ukit-vision-analyst")  [model: unic-vision]';

  const lines = [
    `UKIT VISION ROUTE — image input detected (${cases.join(', ')}).`,
    reasonLine,
    '  1. node .claude/ukit/index/extract-image.mjs --json',
    agentLine,
    '     Pass the ABSOLUTE paths from images[].path as text — a subagent does NOT',
    '     inherit image blocks; it can only Read files.',
  ];
  lines.push('  3. Continue the real task using the returned description.');
  if (markedUrl > 0) {
    lines.push('  Image URL detected: ukit-vision-analyst downloads it with Bash into');
    lines.push('  .ukit/storage/cache/vision/, then Reads the downloaded file.');
  }
  if (localMatches.length > markedPath) {
    lines.push('  A named image path did not resolve from the project root — resolve it before');
    lines.push('  analysing, and do not guess at its contents.');
  }
  if (unicNote) lines.push(unicNote);

  process.stdout.write(`${lines.join('\n')}\n`);
  process.exit(0);
})().catch((err) => {
  process.stderr.write(`vision-router: unexpected error (${err?.message ?? err})\n`);
  process.exit(0);
});
NODE

exit 0
