// lib/process/spec-validator.js — strict, pure validator for workflow specs.
//
// This is the single source of truth for what a workflow spec must look like.
// Both internal seeds (via upsertDefinition) and external authoring (JSON
// editor + agent tool) flow through validateWorkflowSpec. The validator never
// touches the DB; it accepts an optional context with known agent-template
// slugs so the unit tests can simulate "uninstalled template" without a DB.
//
// Contract:
//   validateWorkflowSpec(spec, opts?) → { ok, errors, warnings, normalized }
//
// On ok:false, errors[] is populated and normalized is null.
// On ok:true, normalized is a defensively-cloned canonical form (defaults
// applied, fields placed in a stable order). Warnings can appear in both
// branches but never block.

export const VALID_STAGE_KINDS = new Set([
  'intake_form',
  'agent_run',
  'policy_gate',
  'human_approval',
  'package',
]);

export const VALID_FIELD_TYPES = new Set([
  'text',
  'email',
  'number',
  'textarea',
  'select',
  'shareholders',
]);

export const VALID_RISK_TIERS = new Set([
  'low',
  'medium',
  'high',
  'irreversible_external',
]);

export const CURRENT_SCHEMA_VERSION = 1;
export const MAX_SPEC_BYTES = 64 * 1024;
export const MAX_PROMPT_CHARS = 8000;
const SLUG_REGEX = /^[a-z][a-z0-9-]{2,63}$/;

function pushErr(errors, path, reason) {
  errors.push(`${path}: ${reason}`);
}

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

function validateIntakeForm(stage, errors, prefix) {
  const form = stage?.config?.form_schema;
  if (!isPlainObject(form)) {
    pushErr(errors, `${prefix}.config.form_schema`, 'required (must be object)');
    return;
  }
  if (!Array.isArray(form.fields) || form.fields.length === 0) {
    pushErr(errors, `${prefix}.config.form_schema.fields`, 'must be a non-empty array');
    return;
  }
  const seenNames = new Set();
  form.fields.forEach((field, i) => {
    const fpath = `${prefix}.config.form_schema.fields[${i}]`;
    if (!isPlainObject(field)) { pushErr(errors, fpath, 'must be an object'); return; }
    if (!field.name || typeof field.name !== 'string') pushErr(errors, `${fpath}.name`, 'required (string)');
    if (!field.label || typeof field.label !== 'string') pushErr(errors, `${fpath}.label`, 'required (string)');
    if (!field.type || !VALID_FIELD_TYPES.has(field.type)) {
      pushErr(errors, `${fpath}.type`, `unknown type "${field.type}" (allowed: ${[...VALID_FIELD_TYPES].join(', ')})`);
    }
    if (field.type === 'select') {
      if (!Array.isArray(field.options) || field.options.length === 0) {
        pushErr(errors, `${fpath}.options`, 'select requires a non-empty options[] array');
      }
    }
    if (field.name) {
      if (seenNames.has(field.name)) pushErr(errors, `${fpath}.name`, `duplicate field name "${field.name}"`);
      seenNames.add(field.name);
    }
  });
}

function validateAgentRun(stage, errors, prefix, opts, stageSlugs) {
  const config = stage.config || {};
  if (!config.agent_template_slug || typeof config.agent_template_slug !== 'string') {
    pushErr(errors, `${prefix}.config.agent_template_slug`, 'required (string)');
  } else if (opts.knownAgentTemplates && !opts.knownAgentTemplates.has(config.agent_template_slug)) {
    pushErr(errors, `${prefix}.config.agent_template_slug`, `unknown template "${config.agent_template_slug}"`);
  }
  if (!config.prompt || typeof config.prompt !== 'string') {
    pushErr(errors, `${prefix}.config.prompt`, 'required (string)');
  } else if (config.prompt.length > MAX_PROMPT_CHARS) {
    pushErr(errors, `${prefix}.config.prompt`, `exceeds ${MAX_PROMPT_CHARS} chars (got ${config.prompt.length})`);
  }
  if (config.timeout_ms != null) {
    if (!Number.isInteger(config.timeout_ms) || config.timeout_ms < 1000 || config.timeout_ms > 600000) {
      pushErr(errors, `${prefix}.config.timeout_ms`, 'must be integer 1000..600000');
    }
  }
  if (config.on_outcome != null) {
    if (!isPlainObject(config.on_outcome)) {
      pushErr(errors, `${prefix}.config.on_outcome`, 'must be an object');
    } else {
      for (const [k, target] of Object.entries(config.on_outcome)) {
        if (typeof target !== 'string') {
          pushErr(errors, `${prefix}.config.on_outcome.${k}`, 'value must be a string (stage slug or "fail")');
          continue;
        }
        if (target !== 'fail' && stageSlugs && !stageSlugs.has(target)) {
          pushErr(errors, `${prefix}.config.on_outcome.${k}`, `references non-existent stage "${target}"`);
        }
      }
    }
  }
}

// Predicate operator sets (mirror lib/process/policy-predicates.js).
const PREDICATE_LOGICAL_OPS = new Set(['and', 'or']);
const PREDICATE_COMPARE_OPS = new Set(['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in', 'nin']);
const PREDICATE_MAX_DEPTH = 20;

// Validate a JSON predicate tree structurally. Mirrors the evaluator so an
// invalid predicate is rejected at authoring time, not at case run time.
function validatePredicate(pred, errors, prefix, depth = 0) {
  if (depth > PREDICATE_MAX_DEPTH) {
    pushErr(errors, prefix, 'predicate nesting too deep');
    return;
  }
  if (!isPlainObject(pred)) {
    pushErr(errors, prefix, 'predicate must be an object');
    return;
  }
  const op = pred.op;
  if (PREDICATE_LOGICAL_OPS.has(op)) {
    if (!Array.isArray(pred.rules) || pred.rules.length === 0) {
      pushErr(errors, `${prefix}.rules`, `"${op}" requires a non-empty rules[] array`);
    } else {
      pred.rules.forEach((r, i) => validatePredicate(r, errors, `${prefix}.rules[${i}]`, depth + 1));
    }
  } else if (op === 'not') {
    if (!isPlainObject(pred.rule)) pushErr(errors, `${prefix}.rule`, '"not" requires a rule object');
    else validatePredicate(pred.rule, errors, `${prefix}.rule`, depth + 1);
  } else if (PREDICATE_COMPARE_OPS.has(op)) {
    if (typeof pred.path !== 'string' || !pred.path.trim()) {
      pushErr(errors, `${prefix}.path`, 'required (non-empty string)');
    }
    if (!('value' in pred)) {
      pushErr(errors, `${prefix}.value`, 'required');
    } else if ((op === 'in' || op === 'nin') && !Array.isArray(pred.value)) {
      pushErr(errors, `${prefix}.value`, `"${op}" requires an array value`);
    }
  } else {
    pushErr(errors, `${prefix}.op`, `unknown predicate op "${op}"`);
  }
}

function validatePolicyGate(stage, errors, prefix) {
  const config = stage.config || {};
  if (!Array.isArray(config.rules) || config.rules.length === 0) {
    pushErr(errors, `${prefix}.config.rules`, 'required (non-empty array)');
    return;
  }
  config.rules.forEach((rule, i) => {
    const rpath = `${prefix}.config.rules[${i}]`;
    if (typeof rule === 'string') {
      // Legacy string DSL (deprecated; accepted one release for migration).
      if (!rule.trim()) pushErr(errors, rpath, 'empty rule expression');
    } else if (isPlainObject(rule) && typeof rule.op === 'string') {
      // JSON predicate (canonical form).
      validatePredicate(rule, errors, rpath);
    } else if (isPlainObject(rule)) {
      // Legacy { expr, label } wrapper.
      if (!rule.expr || typeof rule.expr !== 'string' || !rule.expr.trim()) {
        pushErr(errors, `${rpath}.expr`, 'required (non-empty string)');
      }
    } else {
      pushErr(errors, rpath, 'rule must be a JSON predicate, a string, or { expr, label }');
    }
  });
}

function validateHumanApproval(stage, errors, prefix) {
  const config = stage.config || {};
  if (!config.action_type || typeof config.action_type !== 'string') {
    pushErr(errors, `${prefix}.config.action_type`, 'required (string)');
  } else if (!config.action_type.startsWith('process.stage.')) {
    pushErr(errors, `${prefix}.config.action_type`, 'must start with "process.stage."');
  }
  if (!config.risk || !VALID_RISK_TIERS.has(config.risk)) {
    pushErr(errors, `${prefix}.config.risk`, `must be one of ${[...VALID_RISK_TIERS].join(', ')}`);
  }
  if (config.summary_template != null && typeof config.summary_template !== 'string') {
    pushErr(errors, `${prefix}.config.summary_template`, 'must be a string');
  }
}

function validatePackage(stage, errors, prefix, stageSlugs) {
  const config = stage.config || {};
  if (!config.title_template || typeof config.title_template !== 'string') {
    pushErr(errors, `${prefix}.config.title_template`, 'required (string)');
  }
  if (config.include_stages != null) {
    if (!Array.isArray(config.include_stages)) {
      pushErr(errors, `${prefix}.config.include_stages`, 'must be an array of stage slugs');
    } else {
      config.include_stages.forEach((slug, i) => {
        if (typeof slug !== 'string') {
          pushErr(errors, `${prefix}.config.include_stages[${i}]`, 'must be a string');
        } else if (!stageSlugs.has(slug)) {
          pushErr(errors, `${prefix}.config.include_stages[${i}]`, `unknown stage slug "${slug}"`);
        }
      });
    }
  }
}

// Detect cycles in the on_pass graph via DFS. Returns true if any cycle exists.
function detectCycle(stages) {
  const bySlug = new Map(stages.map((s) => [s.slug, s]));
  const WHITE = 0, GRAY = 1, BLACK = 2;
  const color = new Map(stages.map((s) => [s.slug, WHITE]));

  function visit(slug) {
    if (!bySlug.has(slug)) return false;
    const c = color.get(slug);
    if (c === GRAY) return true;
    if (c === BLACK) return false;
    color.set(slug, GRAY);
    const stage = bySlug.get(slug);
    const next = stage.on_pass_stage_slug;
    if (next && next !== 'fail' && visit(next)) return true;
    color.set(slug, BLACK);
    return false;
  }
  for (const s of stages) {
    if (color.get(s.slug) === WHITE && visit(s.slug)) return true;
  }
  return false;
}

export function validateWorkflowSpec(spec, opts = {}) {
  const errors = [];
  const warnings = [];

  // Size cap first — protects everything else from runaway JSON.
  let serialized;
  try {
    serialized = JSON.stringify(spec);
  } catch {
    return { ok: false, errors: ['spec: not JSON-serialisable'], warnings: [], normalized: null };
  }
  if (!serialized || serialized === 'undefined') {
    return { ok: false, errors: ['spec: required'], warnings: [], normalized: null };
  }
  if (Buffer.byteLength(serialized, 'utf8') > MAX_SPEC_BYTES) {
    return {
      ok: false,
      errors: [`spec: serialized length ${Buffer.byteLength(serialized, 'utf8')} bytes exceeds ${MAX_SPEC_BYTES}-byte cap`],
      warnings: [],
      normalized: null,
    };
  }

  if (!isPlainObject(spec)) {
    return { ok: false, errors: ['spec: must be an object'], warnings: [], normalized: null };
  }

  // 1. Top-level required fields.
  if (!spec.slug || typeof spec.slug !== 'string') pushErr(errors, 'slug', 'required (string)');
  else if (!SLUG_REGEX.test(spec.slug)) pushErr(errors, 'slug', `does not match ${SLUG_REGEX} (lowercase, starts with letter, 3-64 chars, [a-z0-9-])`);

  if (!spec.name || typeof spec.name !== 'string') pushErr(errors, 'name', 'required (string)');

  // schema_version is accepted as an informational field. The validator does
  // not reject unknown values today because no second schema version exists
  // yet — adding rejection now would lock us into upgrading every spec when
  // we cut schema v2. Re-introduce the version-mismatch error once the first
  // migration lands. Field stays in `normalized` for forward compatibility.
  const schemaVersion = spec.schema_version ?? CURRENT_SCHEMA_VERSION;
  if (spec.schema_version != null && !Number.isInteger(spec.schema_version)) {
    pushErr(errors, 'schema_version', 'must be an integer');
  }

  if (spec.version != null && (!Number.isInteger(spec.version) || spec.version < 1)) {
    pushErr(errors, 'version', 'must be a positive integer');
  }

  // 4. Stages.
  if (!Array.isArray(spec.stages) || spec.stages.length === 0) {
    pushErr(errors, 'stages', 'required (non-empty array)');
    // Without stages we can't validate any further structural rules — bail.
    return { ok: false, errors, warnings, normalized: null };
  }

  // Collect stage slug set (so per-kind validators can cross-reference).
  const stageSlugs = new Set();
  spec.stages.forEach((stage, i) => {
    const prefix = `stages[${i}]`;
    if (!isPlainObject(stage)) {
      pushErr(errors, prefix, 'must be an object');
      return;
    }
    if (!stage.slug || typeof stage.slug !== 'string') {
      pushErr(errors, `${prefix}.slug`, 'required (string)');
    } else if (!SLUG_REGEX.test(stage.slug)) {
      pushErr(errors, `${prefix}.slug`, `does not match ${SLUG_REGEX}`);
    } else if (stageSlugs.has(stage.slug)) {
      pushErr(errors, `${prefix}.slug`, `duplicate stage slug "${stage.slug}"`);
    } else {
      stageSlugs.add(stage.slug);
    }
    if (!stage.name || typeof stage.name !== 'string') {
      pushErr(errors, `${prefix}.name`, 'required (string)');
    }
    if (!stage.kind || !VALID_STAGE_KINDS.has(stage.kind)) {
      pushErr(errors, `${prefix}.kind`, `unknown kind "${stage.kind}" (allowed: ${[...VALID_STAGE_KINDS].join(', ')})`);
    }
  });

  // 5. First stage MUST be intake_form (current engine assumption).
  const firstStage = spec.stages[0];
  if (firstStage && firstStage.kind && firstStage.kind !== 'intake_form') {
    pushErr(errors, 'stages[0].kind', `first stage must be "intake_form" (got "${firstStage.kind}")`);
  }

  // 6. Per-kind config validators.
  spec.stages.forEach((stage, i) => {
    if (!stage || !VALID_STAGE_KINDS.has(stage.kind)) return;
    const prefix = `stages[${i}]`;
    if (!isPlainObject(stage.config)) {
      pushErr(errors, `${prefix}.config`, 'must be an object');
      return;
    }
    switch (stage.kind) {
      case 'intake_form':    validateIntakeForm(stage, errors, prefix); break;
      case 'agent_run':      validateAgentRun(stage, errors, prefix, opts, stageSlugs); break;
      case 'policy_gate':    validatePolicyGate(stage, errors, prefix); break;
      case 'human_approval': validateHumanApproval(stage, errors, prefix); break;
      case 'package':        validatePackage(stage, errors, prefix, stageSlugs); break;
    }
  });

  // 7. Top-level routing references + cycle + orphan detection. Per-kind
  // config refs (e.g. agent_run on_outcome) are validated inside their
  // respective per-kind validators above.
  spec.stages.forEach((stage, i) => {
    if (!isPlainObject(stage)) return;
    const prefix = `stages[${i}]`;
    if (stage.on_pass_stage_slug != null && stage.on_pass_stage_slug !== 'fail' && !stageSlugs.has(stage.on_pass_stage_slug)) {
      pushErr(errors, `${prefix}.on_pass_stage_slug`, `references non-existent stage "${stage.on_pass_stage_slug}"`);
    }
    if (stage.on_fail_stage_slug != null && stage.on_fail_stage_slug !== 'fail' && !stageSlugs.has(stage.on_fail_stage_slug)) {
      pushErr(errors, `${prefix}.on_fail_stage_slug`, `references non-existent stage "${stage.on_fail_stage_slug}"`);
    }
  });

  // Cycle detection only meaningful if structural errors above are absent.
  if (errors.length === 0 && detectCycle(spec.stages)) {
    pushErr(errors, 'stages', 'routing graph contains a cycle');
  }

  // Orphan detection — warn, don't reject.
  if (errors.length === 0) {
    const inbound = new Set();
    spec.stages.forEach((s) => {
      if (s.on_pass_stage_slug && s.on_pass_stage_slug !== 'fail') inbound.add(s.on_pass_stage_slug);
      if (s.on_fail_stage_slug && s.on_fail_stage_slug !== 'fail') inbound.add(s.on_fail_stage_slug);
      const oo = s?.config?.on_outcome;
      if (isPlainObject(oo)) {
        for (const v of Object.values(oo)) if (typeof v === 'string' && v !== 'fail') inbound.add(v);
      }
    });
    spec.stages.forEach((s, i) => {
      if (i === 0) return; // first stage is the implicit entry point
      if (!inbound.has(s.slug)) warnings.push(`stages[${i}] "${s.slug}": orphan stage (no inbound edges)`);
    });
  }

  if (errors.length > 0) {
    return { ok: false, errors, warnings, normalized: null };
  }

  return { ok: true, errors: [], warnings, normalized: normalize(spec, schemaVersion) };
}

// Defensive deep-clone with defaults applied + stable field ordering.
function normalize(spec, schemaVersion) {
  return {
    schema_version: schemaVersion,
    slug: spec.slug,
    name: spec.name,
    version: spec.version || 1,
    description: spec.description || null,
    category: spec.category || null,
    source: spec.source || 'api',
    status: spec.status || 'draft',
    stages: spec.stages.map((stage) => ({
      slug: stage.slug,
      name: stage.name,
      kind: stage.kind,
      config: structuredClone(stage.config || {}),
      on_pass_stage_slug: stage.on_pass_stage_slug || null,
      on_fail_stage_slug: stage.on_fail_stage_slug || null,
      required: stage.required === false ? false : true,
    })),
  };
}
