#!/usr/bin/env node
/**
 * action-journal — Журнал S1 (несущий №1): append-only хеш-цепочка действий.
 * chain_hash = sha256(prev_hash + canonicalJson(payload)). Правка записи задним
 * числом ломает последующие хеши; голова цепи HMAC-подписана (оптовую перезапись
 * нельзя без ключа). Идея цепи — из audit_chain_hash() (db/schema.sql:75).
 * Лог-до-действия и «нет записи → нет действия» — ENFORCEMENT Машины 2 (верховный
 * хук читает журнал); здесь — примитив записи + tamper-evidence + подпись головы.
 */
import { createHash } from 'node:crypto';
import fsDefault from 'node:fs';
import { canonicalJson, signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';

export const GENESIS_HASH = '0'.repeat(64);

/** chain_hash = sha256(prev_hash + canonicalJson(payload)). */
export function computeEntryHash(prevHash, payload) {
  return createHash('sha256').update(String(prevHash) + canonicalJson(payload)).digest('hex');
}

/**
 * Добавить запись к цепочке (чисто; персист — отдельно в journalAppend).
 * @returns {{entries: object[], headSig: string|null}}
 */
export function appendEntry(entries, payload, { key, nowMs }) {
  const prev = entries.length ? entries[entries.length - 1] : null;
  const prevHash = prev ? prev.chain_hash : GENESIS_HASH;
  const seq = (prev ? prev.seq : 0) + 1;
  const ts = typeof nowMs === 'number' ? nowMs : Date.now();
  const entry = {
    seq,
    ts,
    payload,
    prev_hash: prevHash,
    // B2 (2026-06-05): seq+ts входят в хеш — подмена метаданных задним числом ломает цепь.
    chain_hash: computeEntryHash(prevHash, { seq, ts, payload }),
  };
  const next = [...entries, entry];
  const headSig = signHead(entry.seq, entry.chain_hash, key);
  return { entries: next, headSig };
}

/** Подпись головы: HMAC над {seq, head_hash} (verifyReceipt-совместимая форма). */
export function signHead(seq, headHash, key) {
  return signPayload({ seq, head_hash: headHash }, key, RECEIPT_DOMAINS.JOURNAL_HEAD);
}

/**
 * Проверка цепочки: (1) каждый chain_hash пересчитывается из prev_hash+payload;
 * (2) prev_hash каждой записи = chain_hash предыдущей; (3) подпись головы валидна.
 * @returns {{ok: boolean, brokenAt: number|null}} brokenAt = seq первой битой записи (или null).
 */
export function verifyChain(entries, headSig, { key }) {
  if (!Array.isArray(entries) || entries.length === 0) return { ok: false, brokenAt: null };
  let prevHash = GENESIS_HASH;
  for (const e of entries) {
    // N2: структурно-битая запись (null / не-объект / массив) — fail-closed value,
    // не исключение (внешняя порча .jsonl строкой `null` доходит сюда из loadJournal).
    if (!e || typeof e !== 'object' || Array.isArray(e)) return { ok: false, brokenAt: null };
    if (e.prev_hash !== prevHash) return { ok: false, brokenAt: e.seq ?? null };
    if (computeEntryHash(prevHash, { seq: e.seq, ts: e.ts, payload: e.payload }) !== e.chain_hash) return { ok: false, brokenAt: e.seq ?? null };
    prevHash = e.chain_hash;
  }
  const head = entries[entries.length - 1];
  const headOk = verifyReceipt({ seq: head.seq, head_hash: head.chain_hash, sig: headSig }, key, RECEIPT_DOMAINS.JOURNAL_HEAD);
  if (!headOk) return { ok: false, brokenAt: head.seq };
  return { ok: true, brokenAt: null };
}

// N3 (2026-06-07 расширено до общего guard, аудит M1-M4): sessionId вклеивается в путь
// файла без проверки формы `..`/`/`/`\` → path traversal (запись/чтение вне каталога).
// Единый экспортируемый guard для ВСЕХ sibling-строителей пути машин (action-journal/
// judge-subrun-journal/plan-lock/enforce-supreme-gate) — закрывает класс разом, не точечно.
// throw (fail-closed; в supreme-gate ловится внешним try/catch → block). Источник sessionId —
// stdin-событие harness (R-28, UUID-подобный), guard — защита-в-глубину против дрейфа.
const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
export function assertSafeSessionId(sessionId) {
  if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) {
    throw new Error('invalid sessionId (path-injection guard)');
  }
  return sessionId;
}
function paths(runtimeDir, sessionId) {
  assertSafeSessionId(sessionId);
  const sep = runtimeDir.endsWith('/') ? '' : '/';
  return {
    jsonl: `${runtimeDir}${sep}action-journal-${sessionId}.jsonl`,
    head: `${runtimeDir}${sep}action-journal-${sessionId}.head`,
  };
}

/** Прочитать цепочку из JSONL + подпись головы. Нет файла → пусто. */
export function loadJournal({ sessionId, runtimeDir, fsImpl = fsDefault }) {
  const { jsonl, head } = paths(runtimeDir, sessionId);
  let entries = [];
  try {
    const raw = fsImpl.readFileSync(jsonl, 'utf8');
    entries = String(raw).split('\n').filter(Boolean).map((l) => JSON.parse(l));
  } catch (e) {
    if (e && e.code === 'ENOENT') return { entries: [], headSig: null };
    throw e;
  }
  let headSig = null;
  try { headSig = String(fsImpl.readFileSync(head, 'utf8')).trim() || null; } catch { headSig = null; }
  return { entries, headSig };
}

/**
 * Добавить действие в журнал сессии (персист через Node fs — НЕ Write-tool).
 * @returns {{entry: object, headSig: string|null}}
 */
export function journalAppend({ payload, key, sessionId, runtimeDir, nowMs, fsImpl = fsDefault }) {
  const { entries } = loadJournal({ sessionId, runtimeDir, fsImpl });
  const { entries: next, headSig } = appendEntry(entries, payload, { key, nowMs });
  const entry = next[next.length - 1];
  const { jsonl, head } = paths(runtimeDir, sessionId);
  fsImpl.appendFileSync(jsonl, JSON.stringify(entry) + '\n');
  fsImpl.writeFileSync(head, String(headSig ?? ''));
  return { entry, headSig };
}
