#!/usr/bin/env node
// stop-context-monitor.mjs — graceful-handoff v2.0 (Hybrid Option C from research/06)
//
// Stop hook fires after each model response. Estimates context usage from
// transcript size; at ≥70% (estimated), spawns handoff-pipeline.mjs --auto
// --no-push to write artifact + commit. Push remains user-triggered.
//
// Reconciliation with disable-model-invocation: the spawn calls the script
// DIRECTLY, not the skill. The skill stays manual-only.
//
// Lock file at <transcript_dir>/.handoff-lock-<session_id> prevents repeat
// firing in the same session.
//
// Context resolution (4-step fallback, v2.1):
//   1. payload.context_window.used_percentage  → authoritative, model-agnostic
//   2. payload.context_window.context_window_size + transcript estimate
//   3. MODEL_WINDOWS[payload.model.id] + transcript estimate
//   4. FALLBACK_WINDOW (1M, 2026 default) + transcript estimate
// Token estimation (steps 2-4): char_count / 3.5. Approximation drifts ±10%;
// 70% threshold is conservative buffer.

import { readFileSync, statSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';

const THRESHOLD = 0.70;
const FALLBACK_WINDOW = 1_000_000;
const CHARS_PER_TOKEN = 3.5;

// Model → context window mapping. Sonnet 4.6 has an opt-in 1M tier that is
// not always active and not exposed in payload — use the safer 200k default.
const MODEL_WINDOWS = {
  'claude-opus-4-7': 1_000_000,
  'claude-sonnet-4-6': 200_000,
  'claude-haiku-4-5-20251001': 200_000,
};

// Test injection: tests can override these by setting on the export.
export const __testHooks = {
  spawn: spawnSync,
  fsRead: readFileSync,
  fsStat: statSync,
  fsWrite: writeFileSync,
  fsExists: existsSync,
};

function readStdin() {
  try {
    return readFileSync(0, 'utf-8');
  } catch {
    return '';
  }
}

function estimateUsedPct(transcriptPath, windowSize) {
  try {
    const stat = __testHooks.fsStat(transcriptPath);
    const tokens = stat.size / CHARS_PER_TOKEN;
    return tokens / windowSize;
  } catch {
    return null;
  }
}

// Resolve context usage via the 4-step fallback chain documented above.
// Returns { pct, source } or null if pct cannot be computed.
export function resolveContextSource(payload, transcriptPath) {
  const ctx = payload?.context_window;
  const direct = ctx?.used_percentage;
  if (typeof direct === 'number' && !isNaN(direct) && direct > 0) {
    return { pct: direct / 100, source: 'direct' };
  }

  const payloadSize = ctx?.context_window_size;
  if (typeof payloadSize === 'number' && payloadSize > 0) {
    const pct = estimateUsedPct(transcriptPath, payloadSize);
    return pct == null ? null : { pct, source: 'payload-size' };
  }

  const modelId = payload?.model?.id;
  const mapped = modelId ? MODEL_WINDOWS[modelId] : undefined;
  if (mapped) {
    const pct = estimateUsedPct(transcriptPath, mapped);
    return pct == null ? null : { pct, source: 'model-map' };
  }

  const pct = estimateUsedPct(transcriptPath, FALLBACK_WINDOW);
  return pct == null ? null : { pct, source: 'default-1m' };
}

function emit(output) {
  process.stdout.write(JSON.stringify(output));
}

function main() {
  const raw = readStdin();
  if (!raw.trim()) {
    process.exit(0);
  }
  let payload;
  try {
    payload = JSON.parse(raw);
  } catch {
    process.exit(0);
  }

  const transcriptPath = payload?.transcript_path;
  const sessionId = payload?.session_id || 'unknown';
  if (!transcriptPath) {
    process.exit(0);
  }

  // 4-step resolution: used_percentage → payload-size → model-map → 1M fallback
  const resolved = resolveContextSource(payload, transcriptPath);
  if (resolved == null) {
    process.exit(0);
  }
  const { pct: pctRaw, source } = resolved;
  const pct = Math.round(pctRaw * 100);

  if (pctRaw < THRESHOLD) {
    process.exit(0);
  }

  // Lock file path: based on transcript directory (session-stable),
  // NOT cwd (which can change). See plan revisions #6.
  const lockPath = join(dirname(transcriptPath), `.handoff-lock-${sessionId}`);
  if (__testHooks.fsExists(lockPath)) {
    process.exit(0); // already triggered this session
  }

  // Touch lock first to prevent races on rapid Stop hook firing
  try {
    __testHooks.fsWrite(lockPath, `${sessionId}\n${new Date().toISOString()}\n`, 'utf-8');
  } catch {
    process.exit(0); // can't lock, give up silently
  }

  // Spawn pipeline synchronously (NOT detached) so we can capture output.
  // 25s timeout fits within Stop hook 30s timeout budget.
  const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
  if (!pluginRoot) {
    emit({
      hookSpecificOutput: {
        hookEventName: 'Stop',
        additionalContext: `⚠️ Auto-handoff aborted at est. ${pct}% [kilde: ${source}]: CLAUDE_PLUGIN_ROOT not set, cannot locate handoff-pipeline.mjs.`,
      },
    });
    process.exit(0);
  }

  const pipelineScript = join(pluginRoot, 'scripts', 'handoff-pipeline.mjs');
  const result = __testHooks.spawn(
    'node',
    [pipelineScript, '--auto', '--no-push', '--non-interactive'],
    { encoding: 'utf-8', timeout: 25_000 }
  );

  if (result.error) {
    emit({
      hookSpecificOutput: {
        hookEventName: 'Stop',
        additionalContext: `⚠️ Auto-handoff FAILED at est. ${pct}% [kilde: ${source}]: ${result.error.message}. Run /graceful-handoff manually.`,
      },
    });
    process.exit(0);
  }

  if (result.status !== 0) {
    emit({
      hookSpecificOutput: {
        hookEventName: 'Stop',
        additionalContext: `⚠️ Auto-handoff pipeline exited ${result.status} at est. ${pct}% [kilde: ${source}]. stderr: ${(result.stderr || '').slice(0, 300)}. Run /graceful-handoff manually.`,
      },
    });
    process.exit(0);
  }

  // Parse pipeline JSON; report status to user via additionalContext
  let pipelineResult;
  try {
    pipelineResult = JSON.parse(result.stdout);
  } catch {
    emit({
      hookSpecificOutput: {
        hookEventName: 'Stop',
        additionalContext: `⚠️ Auto-handoff at est. ${pct}% [kilde: ${source}]: pipeline output unparseable. Run /graceful-handoff manually.`,
      },
    });
    process.exit(0);
  }

  const errors = pipelineResult.errors || [];
  if (errors.length > 0) {
    emit({
      hookSpecificOutput: {
        hookEventName: 'Stop',
        additionalContext: `⚠️ Auto-handoff at est. ${pct}% [kilde: ${source}] partially completed with errors: ${errors.join('; ')}. Artifact: ${pipelineResult.artifact_path || 'not written'}. Run git push manually.`,
      },
    });
    process.exit(0);
  }

  emit({
    hookSpecificOutput: {
      hookEventName: 'Stop',
      additionalContext: `⚠️ Auto-handoff utført ved estimert ${pct}% [kilde: ${source}]: artefakt ${pipelineResult.artifact_path}. Push gjenstår — kjør \`git push\` når du er klar.`,
    },
  });
  process.exit(0);
}

// Only run main() when invoked as script, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
  main();
}
