'use strict';

// Session image-ref enrichment.
//
// Background: CTM hands images to Claude/Codex via provider-native paste
// (commit 277cf940). The prompt text that survives in the JSONL transcript
// therefore carries only a compact `[Image #N]` label — the image itself lands
// as a base64 block that the conversation/review parsers deliberately strip
// (lib/jsonl-conversation-parser.js, lib/session-stream.js). On submit the
// client records a per-submission manifest mapping that prompt's images to
// their stored files under DATA_DIR/session-images/<session>/
// (db.recordSessionImageRefs).
//
// This module folds those manifests back onto the matching user messages as
// `image_refs`, which public/js/message-renderer.js already knows how to
// render. Without this the recorded manifests were write-only and thumbnails
// never came back on reload.
//
// Join key: TIMESTAMP, not label. Manifest labels are per-submission (`[Image
// #1]`, `[Image #2]`) while the surviving prompt text uses session-accumulated
// labels (`[Image #8]`), so the two never line up. But the manifest's
// submit-time and the JSONL user-entry time are the same event, observed <2s
// apart in practice — so we match each manifest to the nearest image-bearing
// user message in time (greedy, smallest-gap-first, each side consumed once).
//
// Pure (no fs/db) so the matching logic is unit-testable in isolation; db.js
// supplies the parsed manifests and server.js composes the two.

const IMAGE_TOKEN_RE = /\[Image #\d+\]/gi;
const IMAGE_TOKEN_TEST_RE = /\[Image #\d+\]/i;

// Manifests recorded much further from their prompt than this are treated as a
// non-match rather than risk attaching to an unrelated message. Real gaps are
// sub-second; the wide window only guards against clock skew / queued sends.
const DEFAULT_MATCH_TOLERANCE_MS = 5 * 60 * 1000;

function _basename(value) {
  return String(value || '').replace(/[?#].*$/, '').split(/[\\/]/).filter(Boolean).pop() || '';
}

function _servedImageUrl(filename) {
  const name = String(filename || '').trim();
  if (!name) return '';
  return '/api/images/file/' + encodeURIComponent(name);
}

// The on-disk file is content-hashed and pointed to by the manifest's relative
// `target` (e.g. ../../images/<hash>.png); `filename` is only the original
// upload name and is NOT a real file. Serve by the target basename, falling
// back to `filename` for older manifests that predate the target field.
function _manifestLinkServedName(link) {
  return _basename(link && link.target) || _basename(link && link.filename);
}

function _manifestTimestampMs(manifest) {
  if (!manifest || typeof manifest !== 'object') return NaN;
  // Prefer `submitted_at` — the client's submit time, which is the event the
  // JSONL user-entry timestamp also records. `created_at` is only the manifest
  // file-write time (≈ submit in practice, but diverges for backfilled refs).
  const slug = String(manifest.submitted_at || manifest.submittedAt || '');
  const m = slug.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z$/);
  if (m) return Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], +m[7]);
  const created = manifest.created_at || manifest.createdAt;
  if (created) {
    const ms = new Date(created).getTime();
    if (Number.isFinite(ms)) return ms;
  }
  return NaN;
}

// Normalize raw manifest JSON into the shape the matcher consumes: a parsed
// submit timestamp plus served image URLs. Manifests without a usable timestamp
// or any servable link are dropped. Returned sorted oldest-first.
function buildSessionImageRefs(rawManifests) {
  const out = [];
  for (const manifest of Array.isArray(rawManifests) ? rawManifests : []) {
    if (!manifest || typeof manifest !== 'object') continue;
    const submittedAtMs = _manifestTimestampMs(manifest);
    if (!Number.isFinite(submittedAtMs)) continue;
    const links = [];
    for (const link of Array.isArray(manifest.links) ? manifest.links : []) {
      if (!link || typeof link !== 'object') continue;
      const servedName = _manifestLinkServedName(link);
      const url = _servedImageUrl(servedName);
      if (!url) continue;
      links.push({
        label: String(link.label || '').trim(),
        filename: servedName,
        url,
        mediaType: link.mime_type || link.mediaType || '',
      });
    }
    if (!links.length) continue;
    out.push({ submittedAtMs, links });
  }
  out.sort((a, b) => a.submittedAtMs - b.submittedAtMs);
  return out;
}

function _messageTimestampMs(msg) {
  const raw = msg && (msg.timestamp || msg.created_at || msg.ts);
  if (!raw) return NaN;
  const ms = new Date(raw).getTime();
  return Number.isFinite(ms) ? ms : NaN;
}

function _messageText(msg) {
  if (!msg || typeof msg !== 'object') return '';
  const direct = msg.text ?? msg.content ?? msg.message ?? '';
  if (typeof direct === 'string') return direct;
  if (Array.isArray(direct)) {
    return direct.map((part) => {
      if (typeof part === 'string') return part;
      if (!part || typeof part !== 'object') return '';
      if (typeof part.text === 'string') return part.text;
      if (typeof part.content === 'string') return part.content;
      if (typeof part.message === 'string') return part.message;
      return '';
    }).filter(Boolean).join('\n');
  }
  if (direct && typeof direct === 'object') {
    if (typeof direct.text === 'string') return direct.text;
    if (typeof direct.content === 'string') return direct.content;
    if (typeof direct.message === 'string') return direct.message;
  }
  return '';
}

// Distinct `[Image #N]` tokens in the order they appear, used only to caption
// the thumbnails with labels that match the prompt text where possible.
function _distinctImageTokens(text) {
  const tokens = [];
  const seen = new Set();
  for (const tok of String(text || '').match(IMAGE_TOKEN_RE) || []) {
    const key = tok.toLowerCase();
    if (seen.has(key)) continue;
    seen.add(key);
    tokens.push(tok);
  }
  return tokens;
}

// Re-attach `image_refs` to the user messages each manifest belongs to.
//
// `messages` must be chronological. Matching is greedy smallest-gap-first over
// (manifest, image-token-bearing user message) pairs within `toleranceMs`, with
// each manifest and each message consumed at most once. Returns a new array;
// only enriched messages are shallow-copied (cached objects stay untouched).
function attachSessionImageRefs(messages, manifests, opts = {}) {
  if (!Array.isArray(messages) || !messages.length) return messages;
  const refs = Array.isArray(manifests) ? manifests : [];
  if (!refs.length) return messages;
  const toleranceMs = Number.isFinite(opts.toleranceMs) ? opts.toleranceMs : DEFAULT_MATCH_TOLERANCE_MS;

  // Candidate messages: user prompts that reference an image and carry a time.
  const candidates = [];
  for (let i = 0; i < messages.length; i++) {
    const msg = messages[i];
    if (!msg || msg.role !== 'user') continue;
    const text = _messageText(msg);
    if (!IMAGE_TOKEN_TEST_RE.test(text)) continue;
    const ms = _messageTimestampMs(msg);
    if (!Number.isFinite(ms)) continue;
    candidates.push({ index: i, ms, text });
  }
  if (!candidates.length) return messages;

  const pairs = [];
  for (let m = 0; m < refs.length; m++) {
    const manifest = refs[m];
    for (const cand of candidates) {
      const gap = Math.abs(cand.ms - manifest.submittedAtMs);
      if (gap <= toleranceMs) pairs.push({ gap, manifest: m, message: cand.index });
    }
  }
  if (!pairs.length) return messages;
  pairs.sort((a, b) => a.gap - b.gap);

  const usedManifest = new Set();
  const assignment = new Map(); // message index -> manifest
  for (const pair of pairs) {
    if (usedManifest.has(pair.manifest) || assignment.has(pair.message)) continue;
    usedManifest.add(pair.manifest);
    assignment.set(pair.message, refs[pair.manifest]);
  }
  if (!assignment.size) return messages;

  return messages.map((msg, i) => {
    const manifest = assignment.get(i);
    if (!manifest) return msg;
    const tokens = _distinctImageTokens(_messageText(msg));
    const image_refs = manifest.links.map((link, idx) => ({
      kind: 'image',
      label: tokens[idx] || link.label || '[Image #' + (idx + 1) + ']',
      filename: link.filename,
      url: link.url,
      mediaType: link.mediaType || '',
    }));
    return { ...msg, image_refs };
  });
}

module.exports = {
  buildSessionImageRefs,
  attachSessionImageRefs,
  _servedImageUrl,
  _manifestTimestampMs,
  DEFAULT_MATCH_TOLERANCE_MS,
};
