// Semantic validator for the reflection applier. Returns per-change decisions
// using the ReflectionRejectionCode union (10 codes) defined in ./types.ts.
//
// The same thresholds (name pattern, four-value type, max body bytes) are
// surfaced through the manual-edit path at `src/bridge/memory-api.ts` PUT
// /entries/:name. Keep the two in sync — if you add or rename a rejection
// code, update both files plus the UI message map in MemoryLearningForm.tsx
// (see docs/maintenance-checklist.md item #10).

import { parse as parseYaml } from 'yaml';
import { isValidMemoryName } from '../../user-folder/memory.js';
import type {
  MemoryChange,
  PieceChanges,
  ReflectionInput,
  ReflectionResult,
  ReflectionRejectionCode,
} from './types.js';

// ── Public types ──────────────────────────────────────────────────────────────

export interface ValidatorOpts {
  maxBodyBytes: number;
}

export interface MemoryDecision {
  index: number;
  accepted: boolean;
  code?: ReflectionRejectionCode;
  reason?: string;
}

export interface PieceDecision {
  accepted: boolean;
  code?: ReflectionRejectionCode;
  reason?: string;
}

export interface ValidatorOutput {
  memoryDecisions: MemoryDecision[];
  pieceDecision: PieceDecision | null; // null when should_edit=false
}

// ── Constants ─────────────────────────────────────────────────────────────────

const ALLOWED_TYPES = new Set(['user', 'feedback', 'project', 'reference']);

/** Sentinels that are forbidden in rules[].next — engine-internal only. */
const SENTINELS = new Set(['COMPLETE', 'ABORT', 'ASK']);

// ── Injected-directive content heuristic ───────────────────────────────────────
//
// Threat model: this memory body is about to be persisted and injected into the
// SYSTEM PROMPT of every future task for this user — where the agent holds
// Bash / Write / WebFetch. A poisoned task can launder an instruction through
// the agent's own complete()/lessons output into a "lesson", so structural gates
// (type, name, size) are not enough. This heuristic rejects bodies that read as
// an injected directive aimed at *future agent behavior* — specifically command
// execution and data-exfiltration patterns.
//
// Design principle: SPECIFIC PATTERNS, NOT BROAD KEYWORD BANS. Legitimate
// lessons routinely mention tools (curl, the Bash tool, an API URL) descriptively
// ("the curl-based health check failed", "remember the staging URL is ..."). We
// only fire when a body combines an imperative *command/exfil verb* with a
// *network sink or shell pipeline* — i.e. it tells a future agent to DO something
// dangerous, not merely that something happened. Each pattern below is anchored
// on that combination to keep false positives low.
const INJECTED_DIRECTIVE_PATTERNS: Array<{ re: RegExp; label: string }> = [
  // 1. Pipe-to-shell: `curl ... | sh`, `wget ... | bash`, `... | sh -c`.
  //    This is almost never a legitimate lesson; it is the canonical RCE one-liner.
  {
    re: /\b(?:curl|wget|fetch)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b/i,
    label: 'pipe-to-shell (curl|wget … | sh)',
  },
  // 2. Decode-then-execute: base64/atob decode piped or fed into an interpreter.
  {
    re: /base64\s+(?:--?d|--decode)\b[^\n]*\|\s*(?:ba|z|da)?sh\b/i,
    label: 'base64-decode-and-run',
  },
  {
    re: /\b(?:eval|exec)\s*\(\s*(?:atob|Buffer\.from)\b/i,
    label: 'eval/exec of decoded payload',
  },
  // 3. Exfiltration: an imperative send-verb pointed at a network URL. Requires
  //    BOTH a directive verb AND an http(s) sink in the same body so that merely
  //    citing a URL as reference does not trip it.
  {
    re: /\b(?:post|send|upload|exfiltrat\w*|leak|forward)\b[^\n]{0,80}\bhttps?:\/\//i,
    label: 'exfiltration directive (verb → URL)',
  },
  {
    re: /\bhttps?:\/\/[^\s]+[^\n]{0,40}\b(?:post|send|upload|exfiltrat\w*)\b/i,
    label: 'exfiltration directive (URL → verb)',
  },
  // 4. curl/wget with an explicit upload flag aimed at a URL (-d/--data, -F,
  //    -T/--upload-file, --data-binary). This is "send our data out", not a fetch.
  {
    re: /\b(?:curl|wget)\b[^\n]*\s(?:-d|--data(?:-binary|-raw)?|-F|--form|-T|--upload-file)\b[^\n]*https?:\/\//i,
    label: 'curl/wget upload to URL',
  },
  // 5. Override / persistence directives that try to rewrite the agent's
  //    standing orders. Anchored on imperative phrasing so descriptive prose
  //    ("the agent ignored the previous error") does not match.
  {
    re: /\bignore\s+(?:all\s+)?(?:previous|prior|above|the\s+system)\b[^\n]{0,40}\b(?:instruction|prompt|rule|direction)/i,
    label: 'ignore-previous-instructions override',
  },
  {
    re: /\b(?:always|every\s+time|on\s+(?:each|every)\s+(?:task|run)|before\s+(?:completing|you\s+finish|finishing))\b[^\n]{0,80}\b(?:run|execute|exec|curl|wget|fetch|send|post|upload|eval)\b/i,
    label: 'standing-order to run/send on every task',
  },
];

/**
 * Returns the matched pattern label if the body reads as an injected directive
 * (command execution / data exfiltration / standing-order override), else null.
 *
 * Conservative by construction — see INJECTED_DIRECTIVE_PATTERNS. A null return
 * means "no specific dangerous pattern matched"; it is NOT a guarantee of safety,
 * just a refusal to over-block legitimate lessons.
 */
function detectInjectedDirective(body: string): string | null {
  for (const { re, label } of INJECTED_DIRECTIVE_PATTERNS) {
    if (re.test(body)) return label;
  }
  return null;
}

// ── Main export ───────────────────────────────────────────────────────────────

/**
 * Semantic validator for a parsed LLM ReflectionResult.
 *
 * Validates all memory_changes (up to the 3-entry cap) and piece_changes
 * statically — without touching the filesystem. Returns a ValidatorOutput
 * with per-item decisions (accepted/rejected + reason code).
 *
 * Rejection codes covered:
 *   rejected_unknown_type       — type not in {user, feedback, project, reference}
 *   rejected_bad_name           — name fails isValidMemoryName (pattern / length)
 *   rejected_body_too_large     — body > maxBodyBytes in UTF-8
 *   rejected_injected_directive — body/description reads as an injected agent-steering
 *                                 directive (pipe-to-shell, base64-decode-and-run,
 *                                 exfiltration verb→URL, "ignore previous", "always run …")
 *   rejected_missing_target     — update/merge_into/remove missing merge_target field OR
 *                                 merge_target does not exist in the current memory index
 *   rejected_name_collision     — add with a name that already exists
 *   rejected_target_piece_mismatch — piece_changes.target_piece ≠ input.pieceName
 *   rejected_invalid_yaml       — piece_changes.new_yaml fails YAML parse
 *   rejected_invalid_piece      — new_yaml parses but fails piece-lint
 *   rejected_dangerous_piece    — COMPLETE/ABORT/ASK appear in rules[].next
 *
 * Note: rejected_stale_target (CAS revision mismatch) is raised by the applier
 * at write time, NOT by this static validator — the revision comparison requires
 * reading the live file inside the per-user lock.
 */
export function validateReflectionResult(
  r: ReflectionResult,
  input: ReflectionInput,
  opts: ValidatorOpts,
): ValidatorOutput {
  // Build a set of existing memory entry names for collision / target checks.
  const existing = new Set(input.memoryEntries.map((e) => e.name));

  // Validate each memory change (cap at 3).
  const memoryDecisions: MemoryDecision[] = r.memory_changes
    .slice(0, 3)
    .map((c, i): MemoryDecision => validateMemoryChange(c, i, existing, opts.maxBodyBytes));

  // Validate piece changes (only when should_edit=true).
  let pieceDecision: PieceDecision | null = null;
  if (r.piece_changes?.should_edit) {
    pieceDecision = validatePiece(r.piece_changes, input);
  }

  return { memoryDecisions, pieceDecision };
}

// ── Memory change validator ───────────────────────────────────────────────────

function validateMemoryChange(
  c: MemoryChange,
  index: number,
  existing: Set<string>,
  maxBodyBytes: number,
): MemoryDecision {
  // 1. Type check
  if (!ALLOWED_TYPES.has(c.type)) {
    return {
      index,
      accepted: false,
      code: 'rejected_unknown_type',
      reason: `type="${c.type}" not in {user, feedback, project, reference}`,
    };
  }

  // 2. Name validation
  if (!isValidMemoryName(c.name)) {
    return {
      index,
      accepted: false,
      code: 'rejected_bad_name',
      reason: `name="${c.name}" fails name pattern (1-64 chars, [a-zA-Z0-9_-] only)`,
    };
  }

  // 3. Body size
  if (Buffer.byteLength(c.body, 'utf8') > maxBodyBytes) {
    return {
      index,
      accepted: false,
      code: 'rejected_body_too_large',
      reason: `body is ${Buffer.byteLength(c.body, 'utf8')} bytes, limit is ${maxBodyBytes}`,
    };
  }

  // 3b. CONTENT heuristic: reject bodies that read as an injected directive
  //     aimed at future agent behavior (command execution / exfiltration /
  //     standing-order override). Checks both body and description because both
  //     are persisted to the entry and injected into future system prompts.
  //     This is the only gate that inspects CONTENT rather than structure —
  //     deliberately conservative to avoid blocking legitimate lessons.
  const directiveHit =
    detectInjectedDirective(c.body) ?? detectInjectedDirective(c.description);
  if (directiveHit) {
    return {
      index,
      accepted: false,
      code: 'rejected_injected_directive',
      reason: `entry body/description matches injected-directive pattern: ${directiveHit}`,
    };
  }

  // 4. Collision check (add only)
  if (c.op === 'add' && existing.has(c.name)) {
    return {
      index,
      accepted: false,
      code: 'rejected_name_collision',
      reason: `add with name="${c.name}" but that entry already exists`,
    };
  }

  // 5. merge_target required for update/merge_into/remove
  if (c.op !== 'add') {
    if (!c.merge_target) {
      return {
        index,
        accepted: false,
        code: 'rejected_missing_target',
        reason: `op="${c.op}" requires merge_target field`,
      };
    }
    if (!existing.has(c.merge_target)) {
      return {
        index,
        accepted: false,
        code: 'rejected_missing_target',
        reason: `merge_target="${c.merge_target}" does not exist in current memory index`,
      };
    }
  }

  return { index, accepted: true };
}

// ── Piece change validator ────────────────────────────────────────────────────

function validatePiece(p: PieceChanges, input: ReflectionInput): PieceDecision {
  // 1. Target piece must match the running piece
  if (p.target_piece && p.target_piece !== input.pieceName) {
    return {
      accepted: false,
      code: 'rejected_target_piece_mismatch',
      reason: `target_piece="${p.target_piece}" but running piece is "${input.pieceName}"`,
    };
  }

  // 2. new_yaml must be present
  if (!p.new_yaml) {
    return {
      accepted: false,
      code: 'rejected_invalid_yaml',
      reason: 'new_yaml is null or absent',
    };
  }

  // 3. YAML parse
  let doc: unknown;
  try {
    doc = parseYaml(p.new_yaml);
  } catch (e) {
    return {
      accepted: false,
      code: 'rejected_invalid_yaml',
      reason: String(e),
    };
  }

  // 4. Piece-lint: movements must be a non-empty array
  if (
    !doc ||
    typeof doc !== 'object' ||
    !Array.isArray((doc as Record<string, unknown>)['movements']) ||
    ((doc as Record<string, unknown>)['movements'] as unknown[]).length === 0
  ) {
    return {
      accepted: false,
      code: 'rejected_invalid_piece',
      reason: 'movements must be a non-empty array',
    };
  }

  // 5. Dangerous sentinel check: COMPLETE/ABORT/ASK must not appear in rules[].next
  const movements = (doc as Record<string, unknown>)['movements'] as Array<Record<string, unknown>>;
  for (const movement of movements) {
    const rules = (movement['rules'] ?? []) as Array<Record<string, unknown>>;
    for (const rule of rules) {
      const next = rule['next'];
      if (typeof next === 'string' && SENTINELS.has(next)) {
        return {
          accepted: false,
          code: 'rejected_dangerous_piece',
          reason: `"${next}" appears in rules[].next — use complete() tool instead`,
        };
      }
    }
  }

  // 6. Injected-directive scan. A forked piece's movement text becomes the
  // system prompt of every future task — exactly the instruction sink the
  // memory body gate protects. Apply the same heuristic to each movement's
  // instruction/persona so a laundered "always run …"/exfiltration directive
  // cannot ride into config via new_yaml (defense-in-depth; reflection
  // auto-apply is opt-in and piece edits silent-fork, but the gate belongs here).
  for (const movement of movements) {
    const text = [movement['instruction'], movement['persona']]
      .filter((v): v is string => typeof v === 'string')
      .join('\n');
    const hit = text && detectInjectedDirective(text);
    if (hit) {
      return {
        accepted: false,
        code: 'rejected_injected_directive',
        reason: `movement text matched injected-directive pattern (${hit})`,
      };
    }
  }

  return { accepted: true };
}
