/**
 * @internal
 * event-schema.mjs — single source of truth for conductor event payload shapes.
 *
 * Why this lives in `framework`, not `core`:
 *   `packages/core` depends on `@aperant/framework`. The schema is shared by
 *   the CLI write path (`commands/event.mjs`), the framework lifecycle write
 *   seam (`coordination/store.mjs:appendEvent`), and the core IPC write seam
 *   (`packages/core/src/runtime/orchestration/conductor/event-log.ts`).
 *   Putting it in the lower layer keeps a single rule table that every
 *   writer can import.
 *
 * Shape: zero-dep plain JS validator returning a `{ ok, error? }` result.
 *   We deliberately avoid zod here so `.mjs` callers (event.mjs, store.mjs)
 *   don't need a build step, and so the core TS writer can use the same
 *   identity-shaped result without translating between zod and a custom
 *   error type.
 *
 * Per Codex roundtable lock 2026-05-15:
 *   - `phase.changed` is reserved for PTY busy/idle transitions ONLY.
 *     Plan completion uses `artifact.ready{kind:'plan'}`, not phase.changed.
 *   - `awaiting-input` / `input-rescinded` MUST carry both terminal_id and
 *     request_id so the drawer can pair cards strictly.
 *   - `artifact.ready{kind:'plan'}` is the canonical plan-completion signal
 *     consumed by the conductor's reviewer queue (Step 5's reviewPlan tool).
 */

/** Conductor ops the schema enforces. Other ops pass through unchanged. */
const CONDUCTOR_OPS = new Set([
	'phase.changed',
	'artifact.ready',
	'awaiting-input',
	'input-rescinded',
	'heartbeat',
	'budget.exceeded',
	'terminal.enrolled',
	'terminal.unenrolled',
	'conductor.intent',
	'conductor.cross_check',
	'conductor.realignment',
	// c-4 (action-capable) — gate verdict + boot-replay audit trail
	'conductor.action.executed',
	'conductor.action.gate_blocked',
	'conductor.boot_reconciled',
	// AC21 — MCP-server consent decisions audited to .aperant/events/*.jsonl
	'mcp-consent.approved',
	'mcp-consent.denied',
	'mcp-consent.tofu-reprompt',
])

/** Allowed values for `phase.changed.to` — PTY busy/idle only. */
const ALLOWED_PHASE_VALUES = new Set(['busy', 'idle'])

/** Allowed values for `artifact.ready.kind`. */
const ALLOWED_ARTIFACT_KINDS = new Set(['plan', 'spec', 'build', 'review'])

/**
 * Validate the data payload of a conductor event against the op's contract.
 *
 * @param {string} op
 * @param {Record<string, unknown> | undefined | null} data
 * @returns {{ ok: true } | { ok: false, error: string }}
 */
export function validateConductorEvent(op, data) {
	if (!CONDUCTOR_OPS.has(op)) return { ok: true }
	const d = data && typeof data === 'object' ? data : {}

	switch (op) {
		case 'awaiting-input':
		case 'input-rescinded':
			return requireFields(op, d, ['terminal_id', 'request_id'])

		case 'phase.changed': {
			const r = requireFields(op, d, ['from', 'to', 'terminal_id', 'request_id'])
			if (!r.ok) return r
			if (!ALLOWED_PHASE_VALUES.has(String(d.to))) {
				return {
					ok: false,
					error: `phase.changed.to must be one of [${[...ALLOWED_PHASE_VALUES].join(', ')}], got: ${String(
						d.to,
					)}. Plan completion must use artifact.ready{kind:'plan'}.`,
				}
			}
			return { ok: true }
		}

		case 'artifact.ready': {
			const r = requireFields(op, d, [
				'kind',
				'path',
				'hash',
				'request_id',
				'terminal_id',
				'task_id',
			])
			if (!r.ok) return r
			if (!ALLOWED_ARTIFACT_KINDS.has(String(d.kind))) {
				return {
					ok: false,
					error: `artifact.ready.kind must be one of [${[...ALLOWED_ARTIFACT_KINDS].join(
						', ',
					)}], got: ${String(d.kind)}`,
				}
			}
			return { ok: true }
		}

		case 'conductor.intent':
			return requireFields(op, d, ['terminal_id', 'request_id', 'action', 'reasoning'])

		case 'conductor.cross_check':
			return requireFields(op, d, ['terminal_id', 'request_id'])

		case 'conductor.realignment': {
			const r = requireFields(op, d, ['terminal_id', 'request_id_orig'])
			if (!r.ok) return r
			if (d.attempt_n === undefined || d.attempt_n === null)
				return { ok: false, error: `${op} missing required field(s): attempt_n` }
			return { ok: true }
		}

		case 'terminal.enrolled':
		case 'terminal.unenrolled':
		case 'heartbeat':
		case 'budget.exceeded':
			return requireFields(op, d, ['terminal_id'])

		case 'conductor.action.executed':
			return requireFields(op, d, ['terminal_id', 'request_id', 'action_kind', 'idempotency_key'])

		case 'conductor.action.gate_blocked':
			return requireFields(op, d, [
				'terminal_id',
				'request_id',
				'action_kind',
				'idempotency_key',
				'blocked_reason',
			])

		// AC21 — MCP server consent audit. Three ops share the same
		// payload shape: who approved/denied which server, with which
		// configHash, at what time. session_id is a session-scoped string
		// (chat-session row id).
		case 'mcp-consent.approved':
		case 'mcp-consent.denied':
		case 'mcp-consent.tofu-reprompt':
			return requireFields(op, d, [
				'server_id',
				'config_sha256',
				'decided_at',
				'decided_by',
				'session_id',
			])

		case 'conductor.boot_reconciled': {
			// Fleet-wide event — numeric fields only. All optional but if
			// present must be finite numbers (zero allowed).
			const numFields = ['cleaned_leases', 'replayed', 'start_offset']
			for (const f of numFields) {
				if (d[f] === undefined) continue
				if (typeof d[f] !== 'number' || !Number.isFinite(d[f])) {
					return {
						ok: false,
						error: `${op} field ${f} must be a finite number, got: ${String(d[f])}`,
					}
				}
			}
			return { ok: true }
		}

		default:
			return { ok: true }
	}
}

/** Set of ops the schema validates — exported for test enumeration. */
export const CONDUCTOR_VALIDATED_OPS = CONDUCTOR_OPS

/**
 * @param {string} op
 * @param {Record<string, unknown>} d
 * @param {string[]} fields
 * @returns {{ ok: true } | { ok: false, error: string }}
 */
function requireFields(op, d, fields) {
	const missing = fields.filter((f) => {
		const v = d[f]
		if (typeof v !== 'string' || v.length === 0) return true
		return false
	})
	if (missing.length === 0) return { ok: true }
	return { ok: false, error: `${op} missing required field(s): ${missing.join(', ')}` }
}
