// lib/process/engine.js — process / workflow state machine.
//
// CRITICAL INVARIANT: this module is the ONLY writer of
//   process_cases.current_stage_id, process_cases.current_stage_slug,
//   process_cases.status, and process_cases.context_json.
// Stage handlers receive a read-only case snapshot + a `recordOutcome` callback
// from `runStageExecution`. They cannot move the case forward themselves —
// every transition flows through `transitionTo`.
//
// Tests in tests/process-engine.test.js assert this invariant.

import { getDb, nowIso, newId } from '../db.js';
import { createHeartbeatTask } from '../heartbeat-queue.js';
import { publishLiveEvent, publishStateChanged } from '../live-events.js';
import { registerApprovalDecisionHandler } from '../approvals.js';
import {
  getDefinition,
  getStage,
  getStageBySlug,
  getFirstStage,
  listStages,
  nextStageAfter,
  failureStageAfter,
} from './definitions.js';
import {
  createIntakeFormArtifact,
  createIntakeSubmissionArtifact,
} from './artifacts.js';
import { getSettings as getDefinitionSettings, resolveBrandPresentation } from './definition-settings.js';
import { revokeShareLinksForCaseStage } from '../share-token.js';
import { safeJson } from './util.js';
import { TERMINAL_CASE_STATES, TERMINAL_EXECUTION_STATES } from './status-tokens.js';

function rowToCase(row) {
  if (!row) return null;
  return {
    ...row,
    intake_payload: safeJson(row.intake_payload_json, null),
    context: safeJson(row.context_json, {}),
  };
}

function rowToExecution(row) {
  if (!row) return null;
  return {
    ...row,
    artifact_ids: safeJson(row.artifact_ids_json, []),
    output: safeJson(row.output_json, null),
  };
}

export function getCase(caseId) {
  if (!caseId) return null;
  return rowToCase(getDb().prepare('SELECT * FROM process_cases WHERE id = ?').get(caseId));
}

export function listCases({ project_id, status, definition_slug, limit = 50 } = {}) {
  const db = getDb();
  const where = [];
  const params = [];
  if (project_id) { where.push('project_id = ?'); params.push(project_id); }
  if (status) { where.push('status = ?'); params.push(status); }
  if (definition_slug) { where.push('definition_slug = ?'); params.push(definition_slug); }
  params.push(Math.max(1, Math.min(200, Number(limit) || 50)));
  return db.prepare(`
    SELECT * FROM process_cases
    ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
    ORDER BY updated_at DESC
    LIMIT ?
  `).all(...params).map(rowToCase);
}

export function listExecutions(caseId) {
  if (!caseId) return [];
  return getDb()
    .prepare('SELECT * FROM process_stage_executions WHERE case_id = ? ORDER BY created_at ASC')
    .all(caseId)
    .map(rowToExecution);
}

export function getExecution(executionId) {
  if (!executionId) return null;
  return rowToExecution(
    getDb().prepare('SELECT * FROM process_stage_executions WHERE id = ?').get(executionId)
  );
}

function appendProcessAudit({ case_id, stage_id = null, action, status = 'success', summary, meta = {}, project_id = null, user_id = null }) {
  const db = getDb();
  const id = newId('audit');
  const at = nowIso();
  const fullMeta = { case_id, stage_id, ...meta };
  try {
    db.prepare(`
      INSERT INTO audit_logs (id, at, type, summary, status, meta, project_id, user_id, action_target_id, action_target_type)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'process_case')
    `).run(id, at, action, summary, status, JSON.stringify(fullMeta), project_id, user_id, case_id);
  } catch (err) {
    // Compliance: audit failure cannot abort the engine, but a silent drop
    // makes case state mutate with no trail. Log to stderr so operators see it.
    try { console.warn('[process.audit] failed to write audit row:', err?.message || err, { case_id, action }); } catch { /* observation only */ }
  }
}

// Render the case-level intake-form artifact at startCase time. Failure is
// observation-only — engine state stays the source of truth and the case can
// still progress without a presentation artifact. The artifact is always the
// branded interactive (in-document form) shape; it posts to the stage submit
// endpoint. (The legacy read-only "preview" shape and its MADARX_BRANDED_INTAKE
// flag were removed in v2.14.0 — branded intake is the only start path.)
function seedIntakeArtifact({ definition, firstStage, caseRow, snapshot }) {
  if (firstStage.kind !== 'intake_form') return null;
  try {
    const schema = parseIntakeFormSchema(definition);
    return createIntakeFormArtifact({
      definition,
      schema,
      caseRow,
      settings: snapshot,
      submit_endpoint: `/api/processes/${caseRow.id}/stages/${firstStage.slug}/submit`,
    });
  } catch { return null; }
}

function emitCaseEvent(type, payload) {
  try {
    publishLiveEvent({ type, ...payload });
    publishStateChanged('processes');
  } catch {
    // bus failure is observation-only
  }
}

// ── Public API ───────────────────────────────────────────────────────────────

/**
 * Start a case from a definition slug. The case lands in the first stage. If
 * that stage is intake_form and no payload is supplied, the case stays in
 * `intake` until `submitIntake` is called. Otherwise the engine immediately
 * transitions to the next stage.
 */
export function startCase({ definition_slug, project_id, title, intake_payload = null, created_by_user_id = null, created_by_agent_id = null }) {
  if (!definition_slug) throw new Error('startCase: definition_slug is required');
  if (!project_id) throw new Error('startCase: project_id is required');
  const definition = getDefinition(definition_slug);
  if (!definition) throw new Error(`startCase: definition "${definition_slug}" not found`);

  const firstStage = getFirstStage(definition.id);
  if (!firstStage) throw new Error(`startCase: definition "${definition_slug}" has no stages`);

  const db = getDb();
  const id = newId('case');
  const now = nowIso();
  const resolvedTitle = (title && String(title).trim()) ||
    `${definition.name} — ${now.slice(0, 19).replace('T', ' ')}`;
  const status = firstStage.kind === 'intake_form' && !intake_payload ? 'intake' : 'running';

  db.prepare(`
    INSERT INTO process_cases
      (id, definition_id, definition_slug, project_id, title, status,
       current_stage_id, current_stage_slug, intake_payload_json, context_json,
       created_by_user_id, created_by_agent_id, created_at, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    id,
    definition.id,
    definition.slug,
    project_id,
    resolvedTitle,
    status,
    firstStage.id,
    firstStage.slug,
    intake_payload ? JSON.stringify(intake_payload) : null,
    JSON.stringify({}),
    created_by_user_id,
    created_by_agent_id,
    now,
    now
  );

  // Snapshot the (definition, project) settings into context_json so the
  // in-flight case binds to whatever was configured at start. Editing
  // settings AFTER the case starts must not affect this case — matches the
  // versioning principle on process_definitions.version. Spec §5.2.
  //
  // The snapshot also captures the RESOLVED brand (global brand from
  // /settings/brand merged with workflow-specific presentation overrides).
  // This makes the case immune to subsequent edits to the global brand
  // profile too — the brand the operator started with is the brand the
  // applicant sees end-to-end.
  const settingsSnapshot = getDefinitionSettings({ definition_id: definition.id, project_id });
  const resolvedPresentation = resolveBrandPresentation({ settings: settingsSnapshot });
  const snapshotForContext = settingsSnapshot
    ? { ...settingsSnapshot, brand: resolvedPresentation }
    : { brand: resolvedPresentation };

  // Artifact-first: render an HTML intake-form artifact for the first stage
  // when it is an intake_form. The artifact id is stored on context_json so
  // it can be linked to the intake execution row produced by submitIntake.
  // Failure is observation-only — engine state is unaffected.
  const contextSeed = { definition_settings_snapshot: snapshotForContext };
  const intakeArtifactId = seedIntakeArtifact({
    definition, firstStage, caseRow: { id, title: resolvedTitle, project_id }, snapshot: snapshotForContext,
  });
  if (intakeArtifactId) contextSeed.intake_form_artifact_id = intakeArtifactId;
  if (Object.keys(contextSeed).length > 0) {
    try {
      db.prepare(`
        UPDATE process_cases
           SET context_json = ?, updated_at = ?
         WHERE id = ?
      `).run(JSON.stringify(contextSeed), nowIso(), id);
    } catch { /* observation only */ }
  }

  appendProcessAudit({
    case_id: id,
    stage_id: firstStage.id,
    action: 'process.case.created',
    summary: `Started case "${resolvedTitle}" (${definition.slug})`,
    project_id,
    user_id: created_by_user_id,
    meta: { definition_slug: definition.slug, first_stage_slug: firstStage.slug },
  });

  emitCaseEvent('process.case.created', {
    case_id: id,
    definition_slug: definition.slug,
    project_id,
  });
  emitCaseEvent('process.stage.entered', {
    case_id: id,
    stage_slug: firstStage.slug,
    kind: firstStage.kind,
  });

  // If intake was supplied up-front, consume it immediately.
  if (intake_payload && firstStage.kind === 'intake_form') {
    return submitIntake({ case_id: id, payload: intake_payload, user_id: created_by_user_id });
  }
  // If the first stage is not intake_form, enqueue it.
  if (firstStage.kind !== 'intake_form') {
    enqueueStageExecution({ case_id: id, stage: firstStage });
  }
  return getCase(id);
}

/**
 * Submit an intake form payload for a case currently sitting in an
 * intake_form stage. Marks the intake execution complete, writes the payload
 * onto the case, and advances to the next stage.
 */
export function submitIntake({ case_id, payload, user_id = null }) {
  if (!case_id) throw new Error('submitIntake: case_id is required');
  if (!payload || typeof payload !== 'object') throw new Error('submitIntake: payload is required');
  const c = getCase(case_id);
  if (!c) throw new Error(`submitIntake: case "${case_id}" not found`);
  if (TERMINAL_CASE_STATES.has(c.status)) {
    throw new Error(`submitIntake: case is ${c.status}`);
  }
  const stage = getStage(c.current_stage_id);
  if (!stage || stage.kind !== 'intake_form') {
    throw new Error('submitIntake: current stage is not an intake_form');
  }

  // Server-side intake validation against the definition's form schema.
  // Catches direct API callers that bypass client-side validation.
  const definition = getDefinition(c.definition_id);
  const schema = parseIntakeFormSchema(definition);
  const validationErrors = validateIntakePayload(schema, payload);
  if (validationErrors.length > 0) {
    const err = new Error(`submitIntake: intake validation failed — ${validationErrors.join('; ')}`);
    err.code = 'INTAKE_VALIDATION_FAILED';
    throw err;
  }

  const db = getDb();
  const now = nowIso();
  // Wrap the payload write + execution row + transition in a single
  // transaction so a crash mid-flight cannot leave the case with a payload
  // but no execution row (orphaned in `intake` status forever).
  //
  // The intake artifact is generated AFTER the commit so a failed disk write
  // can never roll back the payload (engine state stays the source of truth;
  // the artifact is presentation evidence).
  let execId;
  db.exec('BEGIN');
  try {
    db.prepare(`
      UPDATE process_cases
         SET intake_payload_json = ?, updated_at = ?
       WHERE id = ?
    `).run(JSON.stringify(payload), now, case_id);

    execId = createStageExecution({
      case_id,
      stage,
      status: 'pass',
      outcome: 'pass',
      output: { intake_submitted: true },
    });
    db.exec('COMMIT');
  } catch (err) {
    try { db.exec('ROLLBACK'); } catch { /* observation only */ }
    throw err;
  }

  // Artifact-first: render an intake-submission artifact (PII-redacted) and
  // attach both the intake-form artifact (from context) and the submission
  // artifact to the just-recorded execution row.
  try {
    const refreshedCase = getCase(case_id);
    const submissionArtifactId = createIntakeSubmissionArtifact({
      definition,
      schema,
      caseRow: refreshedCase,
      intake: payload,
    });
    const linked = [];
    const formArtifactId = refreshedCase?.context?.intake_form_artifact_id;
    if (formArtifactId) linked.push(formArtifactId);
    if (submissionArtifactId) linked.push(submissionArtifactId);
    if (linked.length > 0) {
      db.prepare(`
        UPDATE process_stage_executions
           SET artifact_ids_json = ?, updated_at = ?
         WHERE id = ?
      `).run(JSON.stringify(linked), nowIso(), execId);
    }
  } catch { /* observation only — engine state already committed */ }

  // Revoke any outstanding share-links for this (case, intake-stage) so a
  // leaked link cannot replay a submission after the case has advanced.
  // Spec §13 open Q1: default yes, automatically.
  try { revokeShareLinksForCaseStage({ case_id, stage_id: stage.id }); }
  catch { /* observation only */ }

  emitCaseEvent('process.stage.completed', {
    case_id,
    stage_slug: stage.slug,
    outcome: 'pass',
    execution_id: execId,
  });

  // Advance to the next stage. transitionTo wraps its own write in a
  // separate transaction; if it fails after the intake commit the case
  // stays at 'intake' status with the payload + intake exec row recorded.
  // That state is recoverable by retrying transitionTo.
  const next = nextStageAfter(stage);
  if (!next) {
    return finalizeCase({ case_id, status: 'complete', user_id });
  }
  return transitionTo({
    case_id,
    from_stage_id: stage.id,
    next_stage: next,
    reason: 'intake_submitted',
    user_id,
  });
}

// Parse the form schema off a definition row. Returns null if no schema set.
function parseIntakeFormSchema(definition) {
  if (!definition) return null;
  if (definition.intake_form_schema) return definition.intake_form_schema;
  if (definition.intake_form_schema_json) {
    try { return JSON.parse(definition.intake_form_schema_json); } catch { return null; }
  }
  return null;
}

// Structural validation only — type sanity + size caps to stop API abuse.
// Business rules (required fields, completeness) are the completeness agent's
// job; this layer just prevents oversized payloads and wrong-type values from
// reaching downstream stages. Returns an array of error strings, empty on ok.
const INTAKE_MAX_PAYLOAD_BYTES = 256 * 1024; // 256 KB caps a malicious caller
const INTAKE_MAX_STRING_LEN = 10_000;
const INTAKE_MAX_SHAREHOLDERS = 50; // DoS guard: bounds the array before O(n) math + downstream artifact rendering
function validateIntakePayload(schema, payload) {
  const errors = [];
  // Hard caps first — these apply regardless of schema.
  let serialized;
  try { serialized = JSON.stringify(payload); } catch {
    errors.push('payload: not JSON-serializable');
    return errors;
  }
  if (serialized && serialized.length > INTAKE_MAX_PAYLOAD_BYTES) {
    errors.push(`payload: exceeds ${INTAKE_MAX_PAYLOAD_BYTES} bytes`);
    return errors;
  }
  if (!schema || !Array.isArray(schema.fields)) return errors;
  // Per-field type sanity. We don't check `required` here — the completeness
  // agent owns the business rule for "which fields must be present".
  for (const f of schema.fields) {
    const v = payload?.[f.name];
    if (v == null) continue;
    if (f.type === 'shareholders') {
      if (!Array.isArray(v)) errors.push(`${f.name}: must be an array`);
      else if (v.length > INTAKE_MAX_SHAREHOLDERS) {
        errors.push(`${f.name}: exceeds ${INTAKE_MAX_SHAREHOLDERS} entries`);
      } else {
        // Validate the math when provided — sum must equal 100. This is a
        // structural rule (percentages don't add up doesn't make sense),
        // not a business rule.
        const total = v.reduce((sum, s) => sum + Number(s?.percentage || 0), 0);
        if (v.length > 0 && Math.abs(total - 100) > 0.01) {
          errors.push(`${f.name}: percentages must sum to 100 (got ${total})`);
        }
      }
    } else if (f.type === 'number') {
      if (typeof v !== 'number' && !(typeof v === 'string' && !Number.isNaN(Number(v)))) {
        errors.push(`${f.name}: must be a number`);
      }
    } else if (typeof v === 'string' && v.length > INTAKE_MAX_STRING_LEN) {
      errors.push(`${f.name}: exceeds ${INTAKE_MAX_STRING_LEN} characters`);
    }
  }
  return errors;
}

/**
 * Cancel a case. Idempotent for already-cancelled cases. Rejects cases that
 * are already in a different terminal state.
 */
export function cancelCase({ case_id, reason = null, user_id = null }) {
  const c = getCase(case_id);
  if (!c) throw new Error(`cancelCase: case "${case_id}" not found`);
  if (c.status === 'cancelled') return c;
  if (TERMINAL_CASE_STATES.has(c.status)) {
    throw new Error(`cancelCase: case is ${c.status}`);
  }
  return finalizeCase({ case_id, status: 'cancelled', reason, user_id });
}

// ── Engine internals (also exported for stage handlers) ─────────────────────

/**
 * Record a stage execution row. Used directly by the engine for intake_form
 * (recorded as a passed step on submit) and by stage handlers via the
 * recordOutcome callback.
 */
export function createStageExecution({ case_id, stage, status = 'queued', outcome = null, output = null, heartbeat_task_id = null, attempt = 1, artifact_ids = null }) {
  const db = getDb();
  const id = newId('pse');
  const now = nowIso();
  const startedAt = status === 'queued' ? null : now;
  const completedAt = TERMINAL_EXECUTION_STATES.has(status) ? now : null;
  const artifactIdsJson = Array.isArray(artifact_ids) && artifact_ids.length > 0
    ? JSON.stringify(artifact_ids.filter(Boolean))
    : null;
  db.prepare(`
    INSERT INTO process_stage_executions
      (id, case_id, stage_id, stage_slug, attempt, status, outcome,
       heartbeat_task_id, output_json, artifact_ids_json, started_at, completed_at, created_at, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    id,
    case_id,
    stage.id,
    stage.slug,
    attempt,
    status,
    outcome,
    heartbeat_task_id,
    output ? JSON.stringify(output) : null,
    artifactIdsJson,
    startedAt,
    completedAt,
    now,
    now
  );
  return id;
}

/**
 * Update an existing stage execution's status / outcome / output. Used by
 * stage handlers when they complete. Refuses updates to terminal rows
 * (`pass`, `fail`, `skipped`, `cancelled`) — retries create a new row.
 */
export function updateStageExecution(execution_id, patch = {}) {
  if (!execution_id) throw new Error('updateStageExecution: execution_id is required');
  const existing = getExecution(execution_id);
  if (!existing) throw new Error(`updateStageExecution: execution "${execution_id}" not found`);
  if (TERMINAL_EXECUTION_STATES.has(existing.status)) {
    throw new Error(`updateStageExecution: execution is already ${existing.status}`);
  }
  const now = nowIso();
  const next = {
    status: patch.status ?? existing.status,
    outcome: patch.outcome ?? existing.outcome,
    output_json: patch.output !== undefined ? (patch.output ? JSON.stringify(patch.output) : null) : existing.output_json,
    artifact_ids_json: patch.artifact_ids ? JSON.stringify(patch.artifact_ids) : existing.artifact_ids_json,
    approval_request_id: patch.approval_request_id ?? existing.approval_request_id,
    agent_run_id: patch.agent_run_id ?? existing.agent_run_id,
    error: patch.error ?? existing.error,
  };
  const completedAt = TERMINAL_EXECUTION_STATES.has(next.status) ? now : existing.completed_at;
  const startedAt = existing.started_at || (next.status === 'running' ? now : null);
  getDb().prepare(`
    UPDATE process_stage_executions
       SET status = ?, outcome = ?, output_json = ?, artifact_ids_json = ?,
           approval_request_id = ?, agent_run_id = ?, error = ?,
           started_at = ?, completed_at = ?, updated_at = ?
     WHERE id = ?
  `).run(
    next.status,
    next.outcome,
    next.output_json,
    next.artifact_ids_json,
    next.approval_request_id,
    next.agent_run_id,
    next.error,
    startedAt,
    completedAt,
    now,
    execution_id
  );
  return getExecution(execution_id);
}

/**
 * Enqueue a stage execution as a heartbeat task of type 'process_stage'.
 * The worker (lib/heartbeat-worker.js) dispatches process_stage tasks to
 * `runStageExecution` below.
 */
export function enqueueStageExecution({ case_id, stage, attempt = 1, priority = 5 }) {
  const c = getCase(case_id);
  if (!c) throw new Error(`enqueueStageExecution: case "${case_id}" not found`);
  const execId = createStageExecution({ case_id, stage, status: 'queued', attempt });
  const task = createHeartbeatTask({
    type: 'process_stage',
    priority,
    payload: {
      type: 'process_stage',
      case_id,
      stage_id: stage.id,
      stage_slug: stage.slug,
      execution_id: execId,
    },
  });
  const db2 = getDb();
  // Backfill the heartbeat_task_id link + project_id (the project_id column
  // is added via ALTER TABLE; createHeartbeatTask doesn't write it).
  db2.prepare(`
    UPDATE heartbeat_tasks SET project_id = ?, updated_at = ? WHERE id = ?
  `).run(c.project_id, nowIso(), task.id);
  db2.prepare(`
    UPDATE process_stage_executions SET heartbeat_task_id = ?, updated_at = ? WHERE id = ?
  `).run(task.id, nowIso(), execId);
  return { execution_id: execId, heartbeat_task_id: task.id };
}

/**
 * The engine-only state-mutation primitive. Transitions a case to a new stage
 * or terminal state. Asserts the case is currently in the expected
 * `from_stage_id` (when supplied). Wrapped in a transaction.
 */
export function transitionTo({ case_id, from_stage_id = null, next_stage = null, terminal_status = null, reason = null, user_id = null, append_context = null }) {
  if (!case_id) throw new Error('transitionTo: case_id is required');
  if (!next_stage && !terminal_status) {
    throw new Error('transitionTo: either next_stage or terminal_status is required');
  }
  const db = getDb();
  const now = nowIso();

  db.exec('BEGIN');
  try {
    const current = db.prepare('SELECT * FROM process_cases WHERE id = ?').get(case_id);
    if (!current) throw new Error(`transitionTo: case "${case_id}" not found`);
    // Cancelled cases are terminal — no further transitions, including via
    // a stale heartbeat task or a delayed approval that fires after cancel.
    if (TERMINAL_CASE_STATES.has(current.status)) {
      throw new Error(`transitionTo: case is ${current.status}`);
    }
    if (from_stage_id && current.current_stage_id !== from_stage_id) {
      const err = new Error(
        `transitionTo: conflict — expected current_stage_id ${from_stage_id}, got ${current.current_stage_id}`
      );
      err.code = 'PROCESS_TRANSITION_CONFLICT';
      throw err;
    }

    const mergedContext = append_context
      ? { ...(safeJson(current.context_json, {}) || {}), ...append_context }
      : safeJson(current.context_json, {});

    if (terminal_status) {
      db.prepare(`
        UPDATE process_cases
           SET status = ?, current_stage_id = NULL, current_stage_slug = NULL,
               context_json = ?, completed_at = ?, updated_at = ?
         WHERE id = ?
      `).run(terminal_status, JSON.stringify(mergedContext), now, now, case_id);
    } else {
      db.prepare(`
        UPDATE process_cases
           SET status = 'running', current_stage_id = ?, current_stage_slug = ?,
               context_json = ?, updated_at = ?
         WHERE id = ?
      `).run(next_stage.id, next_stage.slug, JSON.stringify(mergedContext), now, case_id);
    }
    db.exec('COMMIT');
  } catch (err) {
    try { db.exec('ROLLBACK'); } catch { /* observation only */ }
    throw err;
  }

  const c = getCase(case_id);
  if (terminal_status) {
    appendProcessAudit({
      case_id,
      action: 'process.case.complete',
      summary: `Case ${c.title} reached terminal status ${terminal_status}` + (reason ? ` (${reason})` : ''),
      project_id: c.project_id,
      user_id,
      meta: { terminal_status, reason },
    });
    emitCaseEvent('process.case.complete', { case_id, status: terminal_status });
  } else {
    appendProcessAudit({
      case_id,
      stage_id: next_stage.id,
      action: 'process.stage.entered',
      summary: `Entered stage ${next_stage.slug} (${next_stage.kind})`,
      project_id: c.project_id,
      user_id,
      meta: { stage_slug: next_stage.slug, kind: next_stage.kind, reason },
    });
    emitCaseEvent('process.stage.entered', {
      case_id,
      stage_slug: next_stage.slug,
      kind: next_stage.kind,
    });
    // Enqueue the stage if it's an engine-driven kind.
    if (next_stage.kind !== 'intake_form' && next_stage.kind !== 'human_approval') {
      enqueueStageExecution({ case_id, stage: next_stage });
    } else if (next_stage.kind === 'human_approval') {
      // Approval stages are also driven via heartbeat tasks so the
      // pause-for-approval machinery in heartbeat-queue.js can be reused.
      enqueueStageExecution({ case_id, stage: next_stage, priority: 10 });
    }
  }
  return c;
}

function finalizeCase({ case_id, status, reason = null, user_id = null }) {
  return transitionTo({ case_id, terminal_status: status, reason, user_id });
}

/**
 * Called by the heartbeat worker when a process_stage task is claimed. Loads
 * the case + stage + execution, dispatches to the per-kind handler, then
 * advances or fails the case based on the handler's outcome.
 *
 * Returns the heartbeat-worker result envelope (may include
 * `status: 'approval_required'`).
 */
export async function runStageExecution({ case_id, stage_id, execution_id, heartbeat_task_id }) {
  const dispatched = await dispatchStageHandler({ case_id, stage_id, execution_id, heartbeat_task_id });
  if (dispatched.envelope) return dispatched.envelope;
  return applyStageOutcome({
    case: dispatched.case,
    stage: dispatched.stage,
    execution_id,
    outcome: dispatched.outcome,
  });
}

// Load case/stage/execution, mark execution running, resolve + invoke the
// kind's handler. Returns either { envelope } for an early return (skipped /
// missing rows / handler missing) or { case, stage, outcome } for the normal
// path. The split keeps `runStageExecution` thin and lets the outcome-handling
// logic stay in one place.
async function dispatchStageHandler({ case_id, stage_id, execution_id, heartbeat_task_id }) {
  const c = getCase(case_id);
  if (!c) return { envelope: { ok: false, reason: 'case_not_found' } };
  if (TERMINAL_CASE_STATES.has(c.status)) return { envelope: { ok: true, skipped: true, reason: `case_${c.status}` } };
  const stage = getStage(stage_id);
  if (!stage) return { envelope: { ok: false, reason: 'stage_not_found' } };
  const execution = getExecution(execution_id);
  if (!execution) return { envelope: { ok: false, reason: 'execution_not_found' } };
  if (TERMINAL_EXECUTION_STATES.has(execution.status)) {
    return { envelope: { ok: true, skipped: true, reason: `execution_${execution.status}` } };
  }

  updateStageExecution(execution_id, { status: 'running' });

  const handler = await resolveStageHandler(stage.kind);
  if (typeof handler !== 'function') {
    updateStageExecution(execution_id, { status: 'fail', outcome: 'fail', error: `no handler for kind ${stage.kind}` });
    return { envelope: { ok: false, reason: 'handler_missing' } };
  }

  const outcome = await handler({ case: c, stage, execution, heartbeat_task_id });
  return { case: c, stage, outcome };
}

// Write the execution row, advance the case via transitionTo, and return the
// heartbeat-worker envelope. Outcome shapes accepted:
//   { kind: 'pass'|'fail', output, artifact_ids?, agent_run_id?, terminal_status?,
//     approval_required?: { approval_request_id, summary }, error? }
function applyStageOutcome({ case: c, stage, execution_id, outcome }) {
  const case_id = c.id;
  if (outcome?.approval_required) {
    updateStageExecution(execution_id, {
      status: 'awaiting_approval',
      approval_request_id: outcome.approval_required.approval_request_id,
      output: outcome.output || null,
    });
    return {
      ok: true,
      status: 'approval_required',
      approval_request_id: outcome.approval_required.approval_request_id,
      reason: outcome.approval_required.summary || 'Awaiting human approval',
    };
  }

  const failed = outcome?.kind === 'fail' || outcome?.terminal_status === 'rejected' || outcome?.terminal_status === 'failed';
  const status = failed ? 'fail' : 'pass';
  const outputForContext = outcome?.output || { outcome: status };

  updateStageExecution(execution_id, {
    status,
    outcome: status,
    output: outcome?.output || null,
    artifact_ids: outcome?.artifact_ids,
    agent_run_id: outcome?.agent_run_id,
    error: failed ? (outcome?.error || null) : undefined,
  });
  emitCaseEvent('process.stage.completed', { case_id, stage_slug: stage.slug, outcome: status, execution_id });

  if (failed) {
    const failNext = failureStageAfter(stage);
    if (failNext) {
      transitionTo({ case_id, from_stage_id: stage.id, next_stage: failNext, reason: 'stage_fail_routed', append_context: { [stage.slug]: outputForContext } });
    } else {
      transitionTo({
        case_id, from_stage_id: stage.id,
        terminal_status: outcome?.terminal_status || 'rejected',
        reason: outcome?.error || 'stage_failed',
        append_context: { [stage.slug]: outputForContext },
      });
    }
    return { ok: true, status: 'stage_failed' };
  }

  const next = nextStageAfter(stage);
  if (next) {
    transitionTo({ case_id, from_stage_id: stage.id, next_stage: next, reason: 'stage_pass', append_context: { [stage.slug]: outputForContext } });
  } else {
    transitionTo({ case_id, from_stage_id: stage.id, terminal_status: 'complete', reason: 'final_stage_pass', append_context: { [stage.slug]: outputForContext } });
  }
  return { ok: true, status: 'stage_passed' };
}

function kindToHandlerFile(kind) {
  switch (kind) {
    case 'intake_form': return 'intake-form';
    case 'agent_run': return 'agent-run';
    case 'policy_gate': return 'policy-gate';
    case 'human_approval': return 'human-approval';
    case 'package': return 'package';
    default: throw new Error(`unknown stage kind: ${kind}`);
  }
}

// Module-scope cache for stage handler executeStage functions. Populated
// lazily on first dispatch per kind. The switch in kindToHandlerFile is
// exhaustive and throws on unknown kinds, so the import spec is always
// trusted (no path traversal risk via attacker-controlled stage.kind).
const _stageHandlerCache = new Map();
async function resolveStageHandler(kind) {
  if (_stageHandlerCache.has(kind)) return _stageHandlerCache.get(kind);
  const mod = await import(`./stages/${kindToHandlerFile(kind)}.js`);
  const fn = mod.executeStage;
  if (typeof fn !== 'function') return null;
  _stageHandlerCache.set(kind, fn);
  return fn;
}

// Called by approvals.js when a process.stage.approve approval is decided.
// Looks up the paused execution + stage and re-enqueues it (the heartbeat
// queue already auto-resumes via resumeHeartbeatTasksForApproval, but for
// human_approval stages we also need the engine to know to advance, so we
// re-enqueue the stage explicitly here).
//
// In practice the heartbeat queue's resume path requeues the same task and
// `runStageExecution` is called again — the human-approval handler will see
// the approval has been consumed and return `pass`.
export function onApprovalDecided({ approval_request_id, decision }) {
  if (!approval_request_id) return;
  const db = getDb();
  const row = db.prepare(`
    SELECT * FROM process_stage_executions WHERE approval_request_id = ? AND status = 'awaiting_approval'
    ORDER BY created_at DESC LIMIT 1
  `).get(approval_request_id);
  if (!row) return;
  const execution = rowToExecution(row);
  const c = getCase(execution.case_id);
  if (!c) return;
  if (decision === 'denied') {
    // Mark execution as fail and route via on_fail_stage_slug. We need the
    // stage object — load it.
    const stage = getStage(execution.stage_id);
    updateStageExecution(execution.id, {
      status: 'fail',
      outcome: 'fail',
      error: 'approval denied',
    });
    // Cancel the orphan heartbeat task. `resumeHeartbeatTasksForApproval`
    // only fires on approval — without this cleanup, denial leaves the
    // heartbeat row in `approval_required` status forever.
    if (execution.heartbeat_task_id) {
      try {
        db.prepare(`
          UPDATE heartbeat_tasks
             SET status = 'cancelled', completed_at = ?, error = 'approval denied', updated_at = ?
           WHERE id = ? AND status = 'approval_required'
        `).run(nowIso(), nowIso(), execution.heartbeat_task_id);
      } catch { /* observation only */ }
    }
    const failNext = failureStageAfter(stage);
    if (failNext) {
      transitionTo({ case_id: c.id, from_stage_id: stage.id, next_stage: failNext, reason: 'approval_denied' });
    } else {
      transitionTo({ case_id: c.id, from_stage_id: stage.id, terminal_status: 'rejected', reason: 'approval_denied' });
    }
  }
  // On approval, the heartbeat queue already re-queued the task. The
  // human-approval handler will return pass on the next worker tick.
}

export {
  getDefinition,
  getStage,
  getStageBySlug,
  listStages,
  TERMINAL_CASE_STATES,
  TERMINAL_EXECUTION_STATES,
};

// Register at module load. Only acts on process.stage.approve approvals.
registerApprovalDecisionHandler(({ approval_request_id, decision, row }) => {
  if (row?.action_type !== 'process.stage.approve') return;
  onApprovalDecided({ approval_request_id, decision });
});
