// lib/process/policy-predicates.js — JSON predicate evaluator for policy_gate.
//
// Replaces the hand-rolled string DSL (`stages/policy-dsl-legacy.js`) with a
// declarative predicate tree. No tokenizer, no parser, no escaping bugs — a
// predicate is plain JSON that the spec-validator can validate structurally
// and an agent can author without learning an expression grammar.
//
// Shape:
//   { "op": "and",  "rules": [ <predicate>, ... ] }
//   { "op": "or",   "rules": [ <predicate>, ... ] }
//   { "op": "not",  "rule":  <predicate> }
//   { "op": "eq",   "path": "intake.country", "value": "US" }
//   { "op": "gte",  "path": "prior_stage.kyc-risk.risk_score", "value": 0.3 }
//   { "op": "in",   "path": "intake.type", "value": ["MSA", "SOW", "NDA"] }
//
// Path resolution (read-only):
//   intake.<field…>            → case intake payload
//   prior_stage.<slug>.<field…> → that stage's accumulated context output
//   context.<field…>            → raw context bag
//   <bare>                      → resolved against the whole ctx object
//
// Equality (`eq`/`neq`) is intentionally loose (`==`/`!=`) so a `neq null`
// check treats a missing path the same as an explicit null — matching the
// "field must be provided" idiom the legacy DSL expressed as `x != null`.

const MAX_DEPTH = 20;

export const LOGICAL_OPS = new Set(['and', 'or', 'not']);
export const COMPARE_OPS = new Set(['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in', 'nin']);

const COMPARATORS = {
  eq: (a, b) => a == b, // eslint-disable-line eqeqeq
  neq: (a, b) => a != b, // eslint-disable-line eqeqeq
  lt: (a, b) => Number(a) < Number(b),
  lte: (a, b) => Number(a) <= Number(b),
  gt: (a, b) => Number(a) > Number(b),
  gte: (a, b) => Number(a) >= Number(b),
  in: (a, b) => Array.isArray(b) && b.some((v) => v === a),
  nin: (a, b) => Array.isArray(b) && !b.some((v) => v === a),
};

function isPlainObject(v) {
  return v != null && typeof v === 'object' && !Array.isArray(v);
}

function readDeep(obj, path) {
  if (!path) return obj;
  return path.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), obj);
}

// Resolve a `path` string against the evaluation context. `ctx` carries
// `intake` and `context` (the accumulated per-stage output bag).
export function resolvePath(path, ctx) {
  if (typeof path !== 'string' || !path) return undefined;
  const dot = path.indexOf('.');
  const head = dot === -1 ? path : path.slice(0, dot);
  const rest = dot === -1 ? '' : path.slice(dot + 1);
  if (head === 'intake') return readDeep(ctx?.intake, rest);
  if (head === 'context') return readDeep(ctx?.context, rest);
  if (head === 'prior_stage') {
    const slugDot = rest.indexOf('.');
    const slug = slugDot === -1 ? rest : rest.slice(0, slugDot);
    const field = slugDot === -1 ? '' : rest.slice(slugDot + 1);
    return readDeep(ctx?.context?.[slug], field);
  }
  return readDeep(ctx, path);
}

// True when a rule entry is a JSON predicate (vs a legacy string / { expr }).
export function isPredicateRule(rule) {
  return isPlainObject(rule) && typeof rule.op === 'string';
}

// Evaluate a predicate tree against { intake, context }. Throws on a malformed
// predicate so the caller can surface the error in the gate trace.
export function evaluatePredicate(pred, ctx, depth = 0) {
  if (depth > MAX_DEPTH) throw new Error('policy-predicate: maximum nesting depth exceeded');
  if (!isPlainObject(pred)) throw new Error('policy-predicate: predicate must be an object');
  const op = pred.op;
  if (op === 'and' || op === 'or') {
    if (!Array.isArray(pred.rules)) throw new Error(`policy-predicate: "${op}" requires a rules[] array`);
    const results = pred.rules.map((r) => evaluatePredicate(r, ctx, depth + 1));
    return op === 'and' ? results.every(Boolean) : results.some(Boolean);
  }
  if (op === 'not') {
    if (!isPlainObject(pred.rule)) throw new Error('policy-predicate: "not" requires a rule object');
    return !evaluatePredicate(pred.rule, ctx, depth + 1);
  }
  const cmp = COMPARATORS[op];
  if (!cmp) throw new Error(`policy-predicate: unknown op "${op}"`);
  if (typeof pred.path !== 'string' || !pred.path) {
    throw new Error(`policy-predicate: "${op}" requires a path`);
  }
  const left = resolvePath(pred.path, ctx);
  return Boolean(cmp(left, pred.value));
}

// Non-throwing wrapper mirroring the legacy `evaluateRule` return shape.
export function evaluatePredicateSafe(pred, ctx) {
  try {
    return { result: Boolean(evaluatePredicate(pred, ctx)), error: null };
  } catch (err) {
    return { result: false, error: err?.message || String(err) };
  }
}

// Short human-readable label for a predicate when the author supplied none.
export function describePredicate(pred) {
  if (!isPlainObject(pred)) return String(pred);
  if (pred.label) return pred.label;
  if (pred.op === 'and' || pred.op === 'or') {
    return `(${(pred.rules || []).map(describePredicate).join(` ${pred.op} `)})`;
  }
  if (pred.op === 'not') return `not ${describePredicate(pred.rule)}`;
  return `${pred.path} ${pred.op} ${JSON.stringify(pred.value)}`;
}
