// NDJSON span sink — zero-config observability for host-cp.
//
// Subscribes to the host-stream broadcaster and writes one JSON line per
// `span` event to ~/.olam/logs/host.trace.ndjson. Each span carries the
// minimum surface needed for `jq`-based triage: identity, timing, exit.
//
// Wire shape per line:
//   { traceId, spanId, parentSpanId, name, startedAt, durationMs,
//     attributes, events[], exit: { _tag: 'Success'|'Failure', reason? } }
//
// Rotation: single level — at 50MB the file is renamed to `.1` and a
// fresh file is opened. The previous `.1` (if any) is overwritten. We
// keep at most one prior generation; deeper retention belongs to the
// operator's normal disk-management tooling.
//
// Override path with OLAM_TRACE_LOG_PATH (set to /dev/null in tests that
// don't care about file output, or to a temp file to assert on writes).

import { open, mkdir, rename } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { redactSensitive } from './redactor.mjs';

const DEFAULT_ROTATE_BYTES = 50 * 1024 * 1024;
const DEFAULT_LOG_PATH =
  process.env.OLAM_TRACE_LOG_PATH ??
  join(homedir(), '.olam', 'logs', 'host.trace.ndjson');

export async function createNdjsonSpanSink({
  logPath = DEFAULT_LOG_PATH,
  rotateBytes = DEFAULT_ROTATE_BYTES,
  hostStream,
} = {}) {
  await mkdir(dirname(logPath), { recursive: true });
  let fh = await open(logPath, 'a');
  let bytesWritten = (await fh.stat()).size;
  let closed = false;
  let chain = Promise.resolve();

  async function writeLine(line) {
    if (closed) return;
    await fh.write(line);
    bytesWritten += Buffer.byteLength(line);
    if (bytesWritten >= rotateBytes) {
      await fh.close();
      await rename(logPath, `${logPath}.1`);
      fh = await open(logPath, 'a');
      bytesWritten = 0;
    }
  }

  function recordSpan(span = {}) {
    const {
      name, startedAt, endedAt, attributes, events, exit,
      traceId, spanId, parentSpanId, reason,
    } = span;
    const haveTimes = typeof endedAt === 'number' && typeof startedAt === 'number';
    const durationMs = haveTimes ? endedAt - startedAt : null;
    let finalExit;
    if (exit && typeof exit === 'object' && (exit._tag === 'Success' || exit._tag === 'Failure')) {
      finalExit = exit._tag === 'Failure' && exit.reason !== undefined
        ? { _tag: 'Failure', reason: exit.reason }
        : { _tag: exit._tag };
    } else if (!haveTimes) {
      finalExit = reason !== undefined ? { _tag: 'Failure', reason } : { _tag: 'Failure' };
    } else {
      finalExit = { _tag: 'Success' };
    }
    const record = {
      traceId: traceId ?? null,
      spanId: spanId ?? null,
      parentSpanId: parentSpanId ?? null,
      name: name ?? null,
      startedAt: startedAt ?? null,
      durationMs,
      attributes: redactSensitive(attributes ?? {}),
      events: redactSensitive(events ?? []),
      exit: finalExit,
    };
    const next = chain.then(() => writeLine(JSON.stringify(record) + '\n')).catch(() => {});
    chain = next;
    return next;
  }

  let detach = null;
  if (hostStream && typeof hostStream.addSink === 'function') {
    detach = hostStream.addSink(createSseSpanAdapter((payload) => recordSpan(payload)));
  }

  return {
    recordSpan,
    async close() {
      if (closed) return;
      if (detach) detach();
      // Drain queued writes BEFORE flipping the closed flag — `writeLine`
      // bails on `closed`, so flipping first would silently drop spans
      // recorded just prior to shutdown.
      await chain;
      closed = true;
      try { await fh.close(); } catch { /* already closed */ }
    },
  };
}

/**
 * Subscribe an NDJSON sink to `@olam/auth-client`'s `betaResponseEmitter`.
 * Each `beta-response` event becomes a `withCredential.beta-response` span
 * with the beta payload exploded onto `attributes` — downstream `jq`
 * consumers can query e.g.
 *
 *   jq 'select(.name == "withCredential.beta-response")
 *       | {ts: .startedAt, cred: .attributes.credentialName,
 *          cache: .attributes.cacheStatus,
 *          thinking: .attributes.thinkingTokens,
 *          latencyMs: .durationMs}' ~/.olam/logs/host.trace.ndjson
 *
 * Wire is opt-in (call from server boot). Returns a detach function so the
 * subscription can be removed in tests or on shutdown.
 *
 * Pure additive: spans flowing from other sources (docker lifecycle,
 * plan-orchestrator, etc.) are unaffected.
 */
export function attachBetaResponseEvents({ sink, emitter }) {
  if (!sink || typeof sink.recordSpan !== 'function') {
    throw new Error('attachBetaResponseEvents: sink.recordSpan required');
  }
  if (!emitter || typeof emitter.on !== 'function') {
    throw new Error('attachBetaResponseEvents: emitter.on required');
  }

  const handler = (info) => {
    const now = Date.now();
    const latency = typeof info?.latencyMs === 'number' ? info.latencyMs : 0;
    sink.recordSpan({
      name: 'withCredential.beta-response',
      startedAt: now - latency,
      endedAt: now,
      attributes: {
        credentialName: info?.credentialName ?? null,
        credId: info?.credId ?? null,
        betas: Array.isArray(info?.betas) ? [...info.betas] : [],
        cacheStatus: info?.cacheStatus ?? null,
        thinkingTokens: info?.tokenCounts?.thinking ?? null,
        statusCode: typeof info?.statusCode === 'number' ? info.statusCode : null,
        extraHeaders: info?.extraHeaders && typeof info.extraHeaders === 'object'
          ? { ...info.extraHeaders }
          : {},
      },
      exit: { _tag: 'Success' },
    });
  };

  emitter.on('beta-response', handler);
  return () => emitter.off('beta-response', handler);
}

// Duck-typed ServerResponse for host-stream's `addSink`. Parses SSE frames
// (`event: <type>\ndata: <json>\n\n`) and dispatches `event: span` payloads
// to `onSpan`. All other event types are silently ignored — host-stream
// also replays per-type snapshots on attach; the sink is created at boot
// before any spans are broadcast, so replay is a no-op in practice.
function createSseSpanAdapter(onSpan) {
  let buffer = '';
  return {
    writableEnded: false,
    destroyed: false,
    write(chunk) {
      buffer += String(chunk);
      let i;
      while ((i = buffer.indexOf('\n\n')) !== -1) {
        const frame = buffer.slice(0, i);
        buffer = buffer.slice(i + 2);
        if (!frame.startsWith('event: span\n') && !frame.includes('\nevent: span\n')) continue;
        const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
        if (!dataLine) continue;
        try { onSpan(JSON.parse(dataLine.slice(6))); } catch { /* malformed frame */ }
      }
      return true;
    },
    once() { /* no drain handling needed — in-memory adapter never backpressures */ },
    end() { this.writableEnded = true; },
  };
}
