/**
 * engine/untrusted-fence.js — F5 (W-mpeklod3000we69c).
 *
 * Wraps human-authored / external content in
 *   <UNTRUSTED-INPUT source="…">…</UNTRUSTED-INPUT>
 * fences before splicing it into agent prompts. Pairs with the directive in
 * `playbooks/shared-rules.md` and `prompts/cc-system.md` that teaches agents
 * to treat fenced content as data, not instructions.
 *
 * Zero dependencies beyond `engine/shared` (for the ENGINE_DEFAULTS byte cap).
 * Pure helpers — safe to call from poll-time, render-time, and consolidation
 * paths. Source attributes are sanitized so attacker-influenced parts
 * (PR comment author, file paths) cannot break out of the fence header.
 *
 * Contributors adding a new splice site that includes human-authored,
 * external, or otherwise-untrusted content into a prompt MUST wrap it with
 * `wrapUntrusted(content, source)` (or `wrapUntrustedBlock`) — see
 * `docs/security.md` §5 and `CLAUDE.md` "F5" for the policy.
 */

const FENCE_OPEN_PREFIX = '<UNTRUSTED-INPUT';
const FENCE_CLOSE = '</UNTRUSTED-INPUT>';
const FENCE_CLOSE_ESCAPED = '</UNTRUSTED-INPUT-ESCAPED>';

// Match any flavor of the closing tag that an attacker might try to inject:
//   </UNTRUSTED-INPUT>        — bare closer
//   </untrusted-input>        — lowercase
//   </UNTRUSTED-INPUT >       — trailing space before '>'
//   </UNTRUSTED-INPUT attr="x"> — attributes before '>'
// The first capture group is empty/optional; we always rewrite to the canonical
// escaped marker, dropping any pretend-attribute.
const INNER_CLOSE_RE = /<\/UNTRUSTED-INPUT(?:\s[^>]*)?>/gi;

function _shared() {
  // Late require — keep this module loadable in isolated test contexts that
  // bust `engine/shared` from require.cache between runs.
  return require('./shared');
}

function _maxBytes() {
  try {
    const { ENGINE_DEFAULTS } = _shared();
    const n = ENGINE_DEFAULTS && ENGINE_DEFAULTS.untrustedFenceMaxBytes;
    if (typeof n === 'number' && n > 0) return n;
  } catch { /* fall through */ }
  return 64 * 1024;
}

function _truncateUtf8(str, maxBytes) {
  const buf = Buffer.from(String(str), 'utf8');
  if (buf.length <= maxBytes) return { text: String(str), truncatedBytes: 0 };
  // Step back one byte at a time so we don't slice mid-codepoint. The decoder
  // would emit a replacement char otherwise.
  let cut = maxBytes;
  while (cut > 0 && (buf[cut] & 0xC0) === 0x80) cut--;
  const head = buf.slice(0, cut).toString('utf8');
  return { text: head, truncatedBytes: buf.length - cut };
}

function _escapeInnerClosers(content) {
  return String(content).replace(INNER_CLOSE_RE, FENCE_CLOSE_ESCAPED);
}

// Strip characters that would break out of the fence header's source="…"
// attribute. Conservative whitelist — keep ASCII letters/digits and a small
// set of punctuation that real source attributes need.
function _sanitizeSourceToken(value) {
  return String(value == null ? '' : value)
    .replace(/[\r\n\t]+/g, ' ')
    .replace(/[<>"'&`]/g, '')
    .replace(/\s+/g, '_')
    .slice(0, 200);
}

/**
 * Build a canonical source-attribute string. Keys are emitted in a stable,
 * domain-specific order so source-inspection tests can assert literal output.
 *
 * Known shapes:
 *   buildSource('pr-comment', { host, slug, number, author }) →
 *     'pr-comment:<host>:<slug>#<number>:author=<author>'   (GitHub)
 *   buildSource('pr-comment', { host:'ado', org, project, repo, number, author }) →
 *     'pr-comment:ado:<org>/<project>/<repo>!<number>:author=<author>'
 *   buildSource('pinned-note', { path }) → 'pinned-note:<path>'
 *   buildSource('team-notes', { path }) → 'team-notes:<path>'
 *   buildSource('agent-memory', { path }) → 'agent-memory:<path>'
 *   buildSource('inbox', { filename }) → 'inbox:<filename>'
 *   buildSource('wi-reference', { path }) → 'wi-reference:<path>'
 *   buildSource('doc-content', { path }) → 'doc-content:<path>'
 *
 * Unknown shapes fall through to a generic `kind:k=v:k=v` ordering by key,
 * still sanitized.
 */
function buildSource(kind, parts) {
  const k = _sanitizeSourceToken(kind || 'untrusted');
  if (!parts || typeof parts !== 'object') return k;

  const get = (key) => parts[key] == null ? '' : _sanitizeSourceToken(parts[key]);

  if (k === 'pr-comment') {
    const host = get('host');
    const author = get('author');
    if (host === 'ado') {
      const ref = [get('org'), get('project'), get('repo')].filter(Boolean).join('/');
      const num = get('number');
      const tail = num ? `${ref}!${num}` : ref;
      return [k, host, tail, author && `author=${author}`].filter(Boolean).join(':');
    }
    const slug = get('slug');
    const num = get('number');
    const tail = num ? `${slug}#${num}` : slug;
    return [k, host, tail, author && `author=${author}`].filter(Boolean).join(':');
  }

  if (k === 'pinned-note' || k === 'team-notes' || k === 'agent-memory'
   || k === 'wi-reference' || k === 'doc-content' || k === 'doc-selection') {
    return parts.path ? `${k}:${get('path')}` : k;
  }
  if (k === 'inbox') {
    return parts.filename ? `${k}:${get('filename')}` : k;
  }
  if (k === 'wi-description') {
    return parts.wi ? `${k}:${get('wi')}` : k;
  }
  if (k === 'human-feedback') {
    const wi = get('wi');
    const author = get('author');
    return [k, wi, author && `author=${author}`].filter(Boolean).join(':');
  }
  if (k === 'ci-log') {
    const host = get('host');
    const job = get('job');
    const run = get('run');
    return [k, host, job, run].filter(Boolean).join(':');
  }
  if (k === 'steering') {
    // W-mq066js7000fff1f-b: human-authored steering input may be untrusted
    // (e.g. forwarded PR comments). Stable, compact attribution that
    // surfaces the originating source, target agent, and steerId so audit
    // tools can correlate fence to inbox file.
    const source = get('source');
    const agentId = get('agentId');
    const steerId = get('steerId');
    return [
      k,
      source,
      agentId && `agent=${agentId}`,
      steerId && `id=${steerId}`,
    ].filter(Boolean).join(':');
  }

  // Generic fallback: stable key order via Object.keys (insertion order).
  const segs = Object.keys(parts)
    .map(key => {
      const v = get(key);
      return v ? `${_sanitizeSourceToken(key)}=${v}` : '';
    })
    .filter(Boolean);
  return [k, ...segs].join(':');
}

/**
 * Wrap `content` in an <UNTRUSTED-INPUT> fence. Returns '' if `content` is
 * empty or whitespace-only — callers should never see an empty fence in
 * their rendered prompt.
 */
function wrapUntrusted(content, source) {
  const raw = content == null ? '' : String(content);
  if (!raw.trim()) return '';

  const escaped = _escapeInnerClosers(raw);
  const cap = _maxBytes();
  const { text, truncatedBytes } = _truncateUtf8(escaped, cap);
  const body = truncatedBytes > 0
    ? `${text}\n\n[truncated ${truncatedBytes} more bytes]`
    : text;

  const srcAttr = _sanitizeSourceToken(source || 'untrusted');
  return `${FENCE_OPEN_PREFIX} source="${srcAttr}">${body}${FENCE_CLOSE}`;
}

/**
 * Convenience: prepend `\n\n` so callers can splice without worrying about
 * adjacency. Still returns '' for empty content.
 */
function wrapUntrustedBlock(content, source) {
  const fenced = wrapUntrusted(content, source);
  return fenced ? `\n\n${fenced}` : '';
}

module.exports = {
  wrapUntrusted,
  wrapUntrustedBlock,
  buildSource,
  // Constants exported for source-inspection tests.
  FENCE_OPEN_PREFIX,
  FENCE_CLOSE,
  FENCE_CLOSE_ESCAPED,
};
