// lib/tools/composite/spec-validator.js — strict, pure validator for composite tool specs.
//
// A composite tool is a DECLARATIVE RECIPE that chains existing primitive
// registry tools and LLM synthesis steps into a new named tool. No code is
// generated or executed by this validator — it only checks data shape and
// internal consistency.
//
// Recipe model
// ────────────
// A composite spec is a linear ordered list of steps (no graph, no branching).
// Each step produces an implicit output that later steps may reference via
// {{stepId}} or {{stepId.field}} placeholder strings in input_template,
// title_template, and content_ref fields. Referencing {{input.*}} (the
// composite's own input) is always permitted. A step MUST NOT reference a
// step that appears at or after its own position in the steps array (forward
// references are disallowed because execution is strictly sequential).
//
// Risk inheritance
// ────────────────
// The composite's risk_level is the MAXIMUM risk across all steps, using
// RISK_ORDER as the comparison axis. For tool_call steps the effective risk
// is resolved in priority order:
//   1. Explicit step.risk (author-declared override)
//   2. opts.toolRiskByName.get(tool) (runtime registry lookup)
//   3. Default 'medium' (conservative — unknown primitive assumed medium)
// llm_synthesis and artifact_write steps always contribute 'low'. If any step
// resolves to 'irreversible_external', the whole composite inherits it
// immediately (no further steps can lower it).
//
// No-execute / data-only guarantee
// ─────────────────────────────────
// This file has NO imports from DB, fs, or any runtime system. It is safe to
// call in any context including test environments, agent sandboxes, and
// server-side validation hooks.
//
// Contract:
//   validateCompositeSpec(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 with all
// defaults applied. Warnings may appear in either branch — they never block.

// ─── Exported constants ──────────────────────────────────────────────────────

export const COMPOSITE_SLUG_REGEX = /^[a-z][a-z0-9-]{2,63}$/;
export const MAX_COMPOSITE_SPEC_BYTES = 64 * 1024;
export const VALID_STEP_KINDS = new Set(['tool_call', 'llm_synthesis', 'artifact_write']);

// Ordered low→high. 'blocked' is never authorable and is intentionally absent.
export const RISK_ORDER = ['low', 'medium', 'high', 'irreversible_external'];

const MAX_PROMPT_CHARS = 8000;
const MAX_STEPS = 25;
// Step IDs use the same slug-like pattern as composite slugs.
const STEP_ID_REGEX = /^[a-z][a-z0-9-]{0,63}$/;
// Captures {{someId}} and {{someId.field}} — used for forward-ref scanning.
const PLACEHOLDER_RE = /\{\{([a-z][a-z0-9-]*)/g;

// ─── Internal helpers ────────────────────────────────────────────────────────

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

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

/** Returns the numeric rank of a risk tier, or -1 if unrecognised. */
function riskRank(tier) {
  return RISK_ORDER.indexOf(tier);
}

/** Returns the higher-risk tier of two valid tier strings. */
function maxRisk(a, b) {
  return riskRank(a) >= riskRank(b) ? a : b;
}

/**
 * Scan all string leaves in a plain-object tree and collect placeholder step
 * IDs that look like {{someId}} / {{someId.field}}. Returns a Set of ids
 * found. Ignores {{input.*}} references (they always resolve to the
 * composite's own input and are unconditionally allowed).
 */
function collectPlaceholderRefs(obj) {
  const refs = new Set();
  if (typeof obj === 'string') {
    let m;
    PLACEHOLDER_RE.lastIndex = 0;
    while ((m = PLACEHOLDER_RE.exec(obj)) !== null) {
      const id = m[1];
      if (id !== 'input') refs.add(id);
    }
    return refs;
  }
  if (isPlainObject(obj)) {
    for (const v of Object.values(obj)) {
      for (const id of collectPlaceholderRefs(v)) refs.add(id);
    }
  }
  if (Array.isArray(obj)) {
    for (const v of obj) {
      for (const id of collectPlaceholderRefs(v)) refs.add(id);
    }
  }
  return refs;
}

// ─── Per-step validators ─────────────────────────────────────────────────────

/**
 * Validate a tool_call step. Returns the resolved effective risk tier.
 * Uses opts.knownToolNames for existence checks when provided.
 * Uses opts.toolRiskByName for risk lookup when provided.
 */
function validateToolCallStep(step, errors, prefix, opts) {
  if (!step.tool || typeof step.tool !== 'string') {
    pushErr(errors, `${prefix}.tool`, 'required (string)');
  } else if (opts.knownToolNames && !opts.knownToolNames.has(step.tool)) {
    pushErr(errors, `${prefix}.tool`, `unknown tool '${step.tool}'`);
  }

  if (step.input_template != null && !isPlainObject(step.input_template)) {
    pushErr(errors, `${prefix}.input_template`, 'must be a plain object (data only)');
  }

  let effectiveRisk;
  if (step.risk != null) {
    if (!RISK_ORDER.includes(step.risk)) {
      pushErr(errors, `${prefix}.risk`, `must be one of ${RISK_ORDER.join(', ')}`);
      effectiveRisk = 'medium'; // fallback for risk inheritance
    } else {
      effectiveRisk = step.risk;
    }
  } else if (opts.toolRiskByName && typeof step.tool === 'string') {
    effectiveRisk = opts.toolRiskByName.get(step.tool) ?? 'medium';
  } else {
    effectiveRisk = 'medium';
  }

  return effectiveRisk;
}

function validateLlmSynthesisStep(step, errors, prefix) {
  if (!step.prompt || typeof step.prompt !== 'string') {
    pushErr(errors, `${prefix}.prompt`, 'required (non-empty string)');
  } else if (step.prompt.length > MAX_PROMPT_CHARS) {
    pushErr(errors, `${prefix}.prompt`, `exceeds ${MAX_PROMPT_CHARS} chars (got ${step.prompt.length})`);
  }
  return 'low'; // llm_synthesis always contributes low risk
}

function validateArtifactWriteStep(_step, _errors, _prefix) {
  // No required fields beyond id (validated at the step level). Title/content
  // fields are optional and validated for forward refs at the composite level.
  return 'low'; // artifact_write always contributes low risk
}

// ─── Forward-reference checker ───────────────────────────────────────────────

/**
 * Scan placeholder refs in a step's variable-content fields against the set
 * of step IDs that have already been declared (i.e., steps with index <
 * current index). Any ref that names a step id NOT in earlierIds is a forward
 * (or unknown) reference.
 */
function checkForwardRefs(step, errors, prefix, earlierIds) {
  const fieldsToScan = {};

  if (step.kind === 'tool_call' && isPlainObject(step.input_template)) {
    fieldsToScan['input_template'] = step.input_template;
  }
  if (step.kind === 'artifact_write') {
    if (step.title_template != null) fieldsToScan['title_template'] = step.title_template;
    if (step.content_ref != null) fieldsToScan['content_ref'] = step.content_ref;
  }
  if (step.kind === 'llm_synthesis') {
    // Prompts may contain refs too.
    if (step.prompt != null) fieldsToScan['prompt'] = step.prompt;
  }

  for (const [field, value] of Object.entries(fieldsToScan)) {
    const refs = collectPlaceholderRefs(value);
    for (const ref of refs) {
      if (!earlierIds.has(ref)) {
        pushErr(errors, prefix, `references later/unknown step '${ref}' in ${field}`);
      }
    }
  }
}

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

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

  // ── Size cap — 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_COMPOSITE_SPEC_BYTES) {
    return {
      ok: false,
      errors: [
        `spec: serialized length ${Buffer.byteLength(serialized, 'utf8')} bytes exceeds ${MAX_COMPOSITE_SPEC_BYTES}-byte cap`,
      ],
      warnings: [],
      normalized: null,
    };
  }

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

  // ── Top-level fields ──
  if (!spec.slug || typeof spec.slug !== 'string') {
    pushErr(errors, 'slug', 'required (string)');
  } else if (!COMPOSITE_SLUG_REGEX.test(spec.slug)) {
    pushErr(errors, 'slug', `does not match ${COMPOSITE_SLUG_REGEX} (lowercase, starts with letter, 3-64 chars, hyphens allowed)`);
  }

  if (!spec.title || typeof spec.title !== 'string') {
    pushErr(errors, 'title', 'required (non-empty string)');
  }

  if (spec.description != null && typeof spec.description !== 'string') {
    pushErr(errors, 'description', 'must be a string');
  }

  if (spec.input_schema != null && !isPlainObject(spec.input_schema)) {
    pushErr(errors, 'input_schema', 'must be a plain object');
  }

  // ── Steps array ──
  if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
    pushErr(errors, 'steps', 'required (non-empty array)');
    return { ok: false, errors, warnings, normalized: null };
  }

  if (spec.steps.length > MAX_STEPS) {
    pushErr(errors, 'steps', `exceeds ${MAX_STEPS}`);
    return { ok: false, errors, warnings, normalized: null };
  }

  // ── Per-step validation ──
  const seenIds = new Set();
  let compositeRisk = 'low';
  const normalizedSteps = [];

  for (let i = 0; i < spec.steps.length; i++) {
    const step = spec.steps[i];
    const prefix = `steps[${i}]`;

    if (!isPlainObject(step)) {
      pushErr(errors, prefix, 'must be a plain object');
      continue;
    }

    // kind
    if (!step.kind || !VALID_STEP_KINDS.has(step.kind)) {
      pushErr(
        errors,
        `${prefix}.kind`,
        `must be one of ${[...VALID_STEP_KINDS].join(', ')}`
      );
      // Without a valid kind we cannot validate kind-specific fields; skip.
      continue;
    }

    // id — required, slug-like, unique
    if (!step.id || typeof step.id !== 'string') {
      pushErr(errors, `${prefix}.id`, 'required (slug-like string)');
    } else if (!STEP_ID_REGEX.test(step.id)) {
      pushErr(errors, `${prefix}.id`, `does not match ${STEP_ID_REGEX}`);
    } else if (seenIds.has(step.id)) {
      pushErr(errors, `${prefix}.id`, `duplicate step id '${step.id}'`);
    } else {
      seenIds.add(step.id);
    }

    // Forward-reference check (uses the set of ids seen BEFORE this step)
    // We pass a snapshot of seenIds BEFORE adding the current step's id, which
    // was already added above if it passed uniqueness. We reconstruct the
    // "earlier" set by removing the current id temporarily.
    const earlierIds = new Set(seenIds);
    if (step.id) earlierIds.delete(step.id);
    checkForwardRefs(step, errors, prefix, earlierIds);

    // Kind-specific validation + risk contribution
    let stepRisk;
    switch (step.kind) {
      case 'tool_call':
        stepRisk = validateToolCallStep(step, errors, prefix, opts);
        break;
      case 'llm_synthesis':
        stepRisk = validateLlmSynthesisStep(step, errors, prefix);
        break;
      case 'artifact_write':
        stepRisk = validateArtifactWriteStep(step, errors, prefix);
        break;
    }

    compositeRisk = maxRisk(compositeRisk, stepRisk);

    // Build normalized step
    const normStep = { kind: step.kind, id: step.id };
    if (step.kind === 'tool_call') {
      normStep.tool = step.tool ?? null;
      if (step.input_template != null) normStep.input_template = structuredClone(step.input_template);
      normStep.risk = stepRisk;
    } else if (step.kind === 'llm_synthesis') {
      normStep.prompt = step.prompt ?? null;
    } else if (step.kind === 'artifact_write') {
      if (step.title_template != null) normStep.title_template = step.title_template;
      if (step.content_ref != null) normStep.content_ref = step.content_ref;
    }
    normalizedSteps.push(normStep);
  }

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

  const normalized = {
    slug: spec.slug,
    title: spec.title,
    description: spec.description ?? null,
    input_schema: spec.input_schema != null
      ? structuredClone(spec.input_schema)
      : { type: 'object', properties: {} },
    steps: normalizedSteps,
    risk_level: compositeRisk,
  };

  return { ok: true, errors: [], warnings, normalized };
}
