/**
 * engine/pre-dispatch-eval.js — Cheap LLM gate that screens work items for
 * clear/actionable/testable acceptance criteria *before* they are queued for
 * dispatch. Catches impossible or ambiguous items so a slow/expensive agent
 * run isn't burned on something that can't succeed.
 *
 * Conservative scope (per task contract):
 *   - Validation only — never rewrites or "fixes" criteria.
 *   - Default behavior is governed by `ENGINE_DEFAULTS.enablePreDispatchEval`
 *     (P-d2a9f6e5: now `true` by default; per-project override via the
 *     dashboard config UI).
 *   - When `acceptance_criteria` are absent but the work item carries a rich
 *     `description` (≥ DESCRIPTION_MIN_CHARS after trim), the validator
 *     evaluates the description instead of fail-open bypassing — so noop
 *     dispatches authored from a description-only WI are still caught
 *     (P-d2a9f6e5; defense-in-depth for the same incident class as
 *     P-a4f7c2d8 dashboard depends_on bug).
 *   - Fail-open: any LLM error / runtime-unavailable / parse failure resolves
 *     `{ valid: true }` so the gate cannot wedge dispatch.
 *
 * Wired from engine/dispatch.js → addToDispatchWithValidation().
 *
 * Source: Ripley §3 recommendation in
 *   knowledge/architecture/2026-05-11-ripley-daily-architecture-bug-review-ripley-s-investigati.md
 *   (Daily Architecture & Bug Review — 2026-05-11). Lambert + Rebecca debate
 *   rounds reaffirmed: validate-only, no auto-rewrite, opt-in flag.
 *   P-d2a9f6e5 (architecture meeting 2026-05-13): flip default to true and
 *   broaden validator to read description when criteria are missing.
 */

const shared = require('./shared');
const { log } = shared;
const { callLLM } = require('./llm');

const SYSTEM_PROMPT = 'Output only JSON.';
const DEFAULT_TIMEOUT_MS = 60000;
// NOTE: no hardcoded default model. Hardcoding `'haiku'` (Claude shorthand)
// broke the validator on Copilot fleets — copilot.js has
// `modelShorthands: false`, so it shelled out `copilot --model haiku ...`,
// the CLI rejected the unknown id, and every dispatch hit `LLM exit 1` and
// fell open. Resolve the model from the caller's engine config instead
// (W-mp7ig18j000o7996).
// Minimum trimmed description length that justifies a description-only LLM
// validation. Below this we fail-open without burning an LLM call — short
// descriptions don't carry enough signal for the gate to add value.
const DESCRIPTION_MIN_CHARS = 80;

function _extractCriteria(workItem) {
  if (!workItem || typeof workItem !== 'object') return [];
  const candidates = [workItem.acceptance_criteria, workItem.acceptanceCriteria];
  for (const c of candidates) {
    if (Array.isArray(c) && c.length > 0) return c.map(String).filter(Boolean);
  }
  return [];
}

function _extractDescription(workItem) {
  if (!workItem || typeof workItem !== 'object') return '';
  return (workItem.description || '').trim();
}

function _buildPrompt(workItem, criteria) {
  const title = workItem.title || workItem.name || workItem.id || 'untitled';
  const description = _extractDescription(workItem);
  const lines = [
    `Work item: ${title}`,
  ];
  const hasCriteria = Array.isArray(criteria) && criteria.length > 0;
  if (description) lines.push('', 'Description:', description);
  if (hasCriteria) {
    lines.push('', 'Acceptance criteria:');
    for (const c of criteria) lines.push(`- ${c}`);
    lines.push('',
      'Are these acceptance criteria clear, actionable, and testable?',
      'Reply with JSON: {"valid": true|false, "reason": "..."}.');
  } else {
    lines.push('',
      'Is this work item description clear, actionable, and testable?',
      'Reply with JSON: {"valid": true|false, "reason": "..."}.');
  }
  return lines.join('\n');
}

function _parseResponse(text) {
  let body = String(text || '').trim();
  if (!body) return null;
  const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/i);
  if (fence) body = fence[1].trim();
  // Tolerate prose around the JSON object — grab the first top-level {...}
  if (body[0] !== '{') {
    const m = body.match(/\{[\s\S]*\}/);
    if (m) body = m[0];
  }
  try { return JSON.parse(body); } catch { return null; }
}

/**
 * Resolve the model for the validator's `callLLM` call.
 *
 * Priority:
 *   1. `opts.model` — explicit caller override (preserves back-compat for
 *      unit tests that pin a fixture model).
 *   2. `shared.resolveCcModel(<engine sub-config>)` — defer to whatever the
 *      engine has configured for its CC/direct LLM path. Tolerates both the
 *      flat shape (`{ ccModel, defaultCli, ... }`, which is what
 *      `engine/dispatch.js addToDispatchWithValidation` actually passes today)
 *      and a nested shape (`{ engine: { ccModel, ... } }`) for callers that
 *      hand in the whole config blob.
 *   3. `undefined` — let the runtime adapter pick its own default. The Claude
 *      and Copilot adapters skip `--model` entirely when the value is falsy
 *      (engine/runtimes/claude.js:231, engine/runtimes/copilot.js:456), so
 *      Copilot ends up reading the user's `~/.copilot/settings.json` model.
 *
 * Returning `undefined` is intentional and must be preserved through to the
 * `callLLM` call — passing `''` or `null` instead would cause the adapter to
 * still skip the flag (truthy check), but it muddies the contract.
 */
function _resolveModel(opts) {
  if (opts && opts.model) return opts.model;
  const engineCfg = opts && opts.engineConfig
    ? (opts.engineConfig.engine && typeof opts.engineConfig.engine === 'object'
      ? opts.engineConfig.engine
      : opts.engineConfig)
    : undefined;
  return shared.resolveCcModel(engineCfg);
}

/**
 * Validate a work item's acceptance criteria with a fast/cheap LLM call.
 *
 * Validation modes (P-d2a9f6e5):
 *   1. Criteria present → evaluate the criteria (legacy behavior).
 *   2. No criteria but description ≥ DESCRIPTION_MIN_CHARS → evaluate the
 *      description (defense-in-depth for description-only work items).
 *   3. Otherwise → fail-open with `{ valid: true }`.
 *
 * @param {object} workItem - work item with `acceptance_criteria` (or
 *   `acceptanceCriteria`) plus title/description for context.
 * @param {object} [opts]
 * @param {object} [opts.engineConfig] - engine sub-config (flat
 *   `{ ccModel, defaultCli, ... }` as passed by
 *   `engine/dispatch.js addToDispatchWithValidation`, or the nested
 *   `{ engine: { ... } }` whole-config shape). Used both for
 *   runtime/model resolution in `callLLM` and for resolving the validator's
 *   own model via `shared.resolveCcModel`.
 * @param {string} [opts.model] - explicit model override; otherwise the
 *   engine's CC model resolution applies, falling back to `undefined`
 *   (adapter default) when nothing is configured.
 * @param {number} [opts.timeout] - LLM timeout in ms.
 * @returns {Promise<{valid: boolean, reason: string}>}
 */
async function validateAcceptanceCriteria(workItem, opts = {}) {
  const criteria = _extractCriteria(workItem);
  const description = _extractDescription(workItem);
  const hasCriteria = criteria.length > 0;
  const hasUsableDescription = description.length >= DESCRIPTION_MIN_CHARS;

  if (!hasCriteria && !hasUsableDescription) {
    return { valid: true, reason: 'no acceptance criteria to validate' };
  }

  const prompt = _buildPrompt(workItem, criteria);
  let result;
  try {
    result = await callLLM(prompt, SYSTEM_PROMPT, {
      timeout: Number(opts.timeout) > 0 ? Number(opts.timeout) : DEFAULT_TIMEOUT_MS,
      label: 'pre-dispatch-eval',
      model: _resolveModel(opts),
      maxTurns: 1,
      direct: true,
      // Direct callLLM spawns the runtime CLI with cwd=MINIONS_DIR (engine/llm.js
      // _spawnProcess). Without an allowedTools clamp the validator has full
      // Edit/Write/Bash access to D:\squad and can leak partial implementations
      // outside any worktree — confirmed in P-b5e2a481 (2026-06-01 23:44 UTC):
      // the validator "previewed" the work by editing dashboard/js/refresh.js
      // in MINIONS_DIR before the actual implement agent ran in its worktree.
      // Read-only tools only — the eval is a JSON-decision call.
      allowedTools: 'Read,Grep,Glob',
      engineConfig: opts.engineConfig,
    });
  } catch (e) {
    log('warn', `pre-dispatch-eval: LLM call threw — failing open: ${e?.message || e}`);
    return { valid: true, reason: `validator error: ${e?.message || 'unknown'}` };
  }

  if (!result) {
    return { valid: true, reason: 'validator returned no result' };
  }
  if (result.missingRuntime) {
    log('warn', 'pre-dispatch-eval: runtime unavailable — failing open');
    return { valid: true, reason: 'validator runtime unavailable' };
  }
  if (result.code !== 0) {
    log('warn', `pre-dispatch-eval: LLM exit ${result.code} — failing open: ${result.errorMessage || ''}`);
    return { valid: true, reason: `validator exit ${result.code}` };
  }

  const parsed = _parseResponse(result.text);
  if (!parsed || typeof parsed.valid !== 'boolean') {
    log('warn', 'pre-dispatch-eval: response missing boolean valid field — failing open');
    return { valid: true, reason: 'validator response unparseable' };
  }
  const defaultReason = hasCriteria
    ? (parsed.valid ? 'criteria look testable' : 'criteria not clear/actionable/testable')
    : (parsed.valid ? 'description looks testable' : 'description not clear/actionable/testable');
  return {
    valid: parsed.valid,
    reason: String(parsed.reason || '').trim() || defaultReason,
  };
}

module.exports = {
  validateAcceptanceCriteria,
  // Exposed for unit testing — engine code MUST go through validateAcceptanceCriteria.
  _extractCriteria,
  _extractDescription,
  _buildPrompt,
  _parseResponse,
  _resolveModel,
  DESCRIPTION_MIN_CHARS,
};
