#!/usr/bin/env node
// Hook: pre-compact-flush.mjs
// Event: PreCompact (Claude Code v2.1.105+)
// Purpose: Flush progress.json drift before context compaction so
//          /ultraexecute-local --resume works after long conversations.
//          Direct fix for the documented P0 in
//          docs/ultraexecute-v2-observations-from-config-audit-v4.md.
//
//          v3.3.0: also refreshes sibling .session-state.local.json
//          (Handover 7) so /ultracontinue can detect a resumable session
//          even after a compaction event mid-run.
//
// Behavior:
//   1. Locate {cwd}/.claude/projects/* / progress.json (any nested project)
//   2. Read progress.json + sibling plan.md
//   3. Run `git log --oneline {session_start_sha}..HEAD`
//   4. For each commit, match against plan steps' commit_message_pattern
//   5. If derived current_step > stored current_step → write fresh checkpoint
//      atomically (tmp + rename), monotonic only (current_step never decreases).
//   6. Refresh sibling .session-state.local.json if present and status is
//      resumable (in_progress | partial) — bumps updated_at only. Never
//      creates the state file; creation is the writer's job at session-end.
//      Skips if status is completed/failed/stopped (non-resumable or terminal).
//   7. Always exit 0 — NEVER blocks compaction.
//
// v3.3.0:
//   - atomicWrite extracted to lib/util/atomic-write.mjs for reuse
//   - File reformatted (removed pre-existing leading-whitespace syntax error
//     that silently broke the hook since v3.1.0; PreCompact swallowed it)
//   - Added Handover 7 sibling-state refresh

import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { atomicWriteJson } from '../../lib/util/atomic-write.mjs';

const HERE = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(HERE, '..', '..');

function findProgressFiles(cwd) {
  const projectsDir = join(cwd, '.claude', 'projects');
  if (!existsSync(projectsDir) || !statSync(projectsDir).isDirectory()) return [];
  const out = [];
  for (const entry of readdirSync(projectsDir)) {
    const projDir = join(projectsDir, entry);
    if (!statSync(projDir).isDirectory()) continue;
    const progPath = join(projDir, 'progress.json');
    if (existsSync(progPath) && statSync(progPath).isFile()) {
      out.push({ projDir, progPath, planPath: join(projDir, 'plan.md') });
    }
  }
  return out;
}

function readJson(path) {
  try { return JSON.parse(readFileSync(path, 'utf-8')); }
  catch { return null; }
}

function readPlanCheckpointPatterns(planPath) {
  if (!existsSync(planPath)) return new Map();
  const text = readFileSync(planPath, 'utf-8');
  const map = new Map();
  const stepRe = /^### Step (\d+):/gm;
  const checkpointRe = /\*\*Checkpoint:\*\*\s+`git commit -m "([^"]+)"`/;
  const headings = [];
  let m;
  while ((m = stepRe.exec(text)) !== null) {
    headings.push({ n: Number.parseInt(m[1], 10), idx: m.index });
  }
  for (let i = 0; i < headings.length; i++) {
    const start = headings[i].idx;
    const end = i + 1 < headings.length ? headings[i + 1].idx : text.length;
    const body = text.slice(start, end);
    const cp = body.match(checkpointRe);
    if (cp) {
      const msg = cp[1];
      const conventionalPrefix = (msg.match(/^([a-z]+)\(([^)]+)\):/) || [])[0];
      if (conventionalPrefix) map.set(headings[i].n, conventionalPrefix);
    }
  }
  return map;
}

function gitLog(repoDir, baseSha) {
  if (!baseSha) return [];
  try {
    const out = execSync(`git -C "${repoDir}" log --pretty=format:'%H %s' ${baseSha}..HEAD 2>/dev/null`, {
      encoding: 'utf-8', timeout: 5000,
    });
    return out.trim().split('\n').filter(Boolean).map(line => {
      const sp = line.indexOf(' ');
      return { sha: line.slice(0, sp), subject: line.slice(sp + 1) };
    });
  } catch { return []; }
}

function deriveCurrentStep(progress, plan, gitCommits) {
  if (!progress || !progress.steps || gitCommits.length === 0) return null;
  const stored = progress.current_step || 0;
  let highestMatched = stored;
  for (const [stepN, prefix] of plan.entries()) {
    const matchedCommit = gitCommits.find(c => c.subject.startsWith(prefix.replace(/\\/g, '')));
    if (matchedCommit && stepN > highestMatched) highestMatched = stepN;
  }
  return highestMatched;
}

function repoRootOf(dir) {
  try {
    return execSync(`git -C "${dir}" rev-parse --show-toplevel 2>/dev/null`, { encoding: 'utf-8', timeout: 2000 }).trim();
  } catch { return null; }
}

// Resumable statuses for .session-state.local.json. `completed` is terminal;
// `failed`/`stopped` are operator-action-required and should NOT be silently
// refreshed by a background hook (would mask the alert). We only bump
// updated_at for in_progress | partial — the active-work statuses.
const SESSION_STATE_REFRESHABLE = new Set(['in_progress', 'partial']);

function refreshSessionState(projDir) {
  const statePath = join(projDir, '.session-state.local.json');
  if (!existsSync(statePath)) return false;
  const state = readJson(statePath);
  if (!state || typeof state !== 'object') return false;
  if (!SESSION_STATE_REFRESHABLE.has(state.status)) return false;
  // Monotonic guard: only mutate updated_at. Never touch status, project,
  // next_session_*. The writer (Phase 8 / helper) owns those fields.
  state.updated_at = new Date().toISOString();
  atomicWriteJson(statePath, state);
  return true;
}

let stdinPayload = '';
try { stdinPayload = readFileSync(0, 'utf-8'); } catch { /* fine */ }

const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const progressFiles = findProgressFiles(cwd);

if (progressFiles.length === 0) {
  process.exit(0);
}

let mutationsMade = 0;
for (const { projDir, progPath, planPath } of progressFiles) {
  const progress = readJson(progPath);
  if (!progress || progress.status === 'completed') continue;

  const repoRoot = repoRootOf(projDir);
  if (!repoRoot) continue;

  const plan = readPlanCheckpointPatterns(planPath);
  if (plan.size === 0) continue;

  const sessionStart = progress.session_start_sha;
  if (!sessionStart) continue;

  const commits = gitLog(repoRoot, sessionStart);
  const derivedStep = deriveCurrentStep(progress, plan, commits);

  if (derivedStep !== null && derivedStep > (progress.current_step || 0)) {
    progress.current_step = derivedStep;
    progress.updated_at = new Date().toISOString();
    if (!progress.steps[String(derivedStep)]) {
      progress.steps[String(derivedStep)] = {
        status: 'completed', attempts: 1, error: null,
        completed_at: progress.updated_at, commit: null, manifest_audit: 'n/a',
        note: 'reconstructed by pre-compact-flush from git log',
      };
    }
    atomicWriteJson(progPath, progress);
    process.stderr.write(`[ultraplan-local] pre-compact flush: ${progPath} -> current_step=${derivedStep}\n`);
    mutationsMade++;
  }

  // Sibling .session-state.local.json refresh (Handover 7). Independent of
  // progress.json mutation — the state file may exist for a session that
  // hasn't advanced step yet, and we still want updated_at to track liveness.
  if (refreshSessionState(projDir)) {
    process.stderr.write(`[ultraplan-local] pre-compact refresh: ${projDir}/.session-state.local.json\n`);
    mutationsMade++;
  }
}

process.exit(0);
