#!/usr/bin/env node
/**
 * PTY Wrapper — spawns interactive claude via node-pty, streams transcript, emits result.
 *
 * Spawned by JobRunner as a normal child process. Internally uses node-pty to run
 * interactive `claude` (no -p flag), uses a Stop hook for completion detection via
 * a sentinel file, and tails Claude's transcript JSONL for live streaming.
 *
 * Args:
 *   --session-id <uuid>   Session ID (first run)
 *   --resume <uuid>       Resume session (continuation)
 *   --model <model>       Model override
 *   --skip-permissions    Enable --dangerously-skip-permissions
 *
 * Stdin:  The prompt text (piped by JobRunner before spawn)
 * Stdout: JSONL: {type:"transcript",entry:{...}} per line, then {type:"result",...}
 * Exit:   0 on success (or SIGINT with valid sentinel), non-zero on failure
 */

import * as pty from 'node-pty';
import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, realpathSync } from 'fs';
import { join } from 'path';
import { tmpdir, homedir } from 'os';
import { execSync } from 'child_process';

// --- Arg parsing ---

const args = process.argv.slice(2);
const getArg = (name) => {
    const idx = args.indexOf(name);
    return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
};
const hasFlag = (name) => args.includes(name);

const sessionId = getArg('--session-id');
const resumeId = getArg('--resume');
const model = getArg('--model');
const skipPermissions = hasFlag('--skip-permissions');

if (!sessionId && !resumeId) {
    process.stderr.write('Error: Either --session-id or --resume must be provided\n');
    process.exit(1);
}

const effectiveSessionId = sessionId || resumeId;

// --- Logging (stderr only — stdout is reserved for streaming output) ---

const log = (msg) => process.stderr.write(`[pty-wrapper] ${msg}\n`);

// --- Read prompt from stdin ---

const promptChunks = [];
for await (const chunk of process.stdin) {
    promptChunks.push(chunk);
}
const prompt = Buffer.concat(promptChunks).toString('utf-8').trim();

if (!prompt) {
    process.stderr.write('Error: No prompt provided on stdin\n');
    process.exit(1);
}

// --- Resolve claude binary ---

let claudePath;
try {
    claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
    if (!claudePath) throw new Error('empty result');
} catch {
    process.stderr.write('Error: claude binary not found in PATH\n');
    process.exit(1);
}

// --- Temp directory for settings, hook script, and sentinel file ---

const tempDir = mkdtempSync(join(tmpdir(), 'caw-pty-'));
const sentinelPath = join(tempDir, 'result.json');
const hookScriptPath = join(tempDir, 'stop-hook.sh');
const settingsPath = join(tempDir, 'settings.json');
const claudePidPath = join(tempDir, 'claude.pid');

// --- Write Stop hook script ---
// The hook: (1) cats Stop hook JSON (on stdin) to sentinel file, then
// (2) SIGINTs the specific claude PID recorded in claude.pid.
// Using a PID file avoids a fuzzy *claude* tree walk that could accidentally
// SIGINT the calling agent process (e.g. when running under a Claude agent).

const hookScript = [
    '#!/bin/bash',
    `cat > '${sentinelPath}'`,
    `kill -INT $(cat '${claudePidPath}') 2>/dev/null`,
    ''
].join('\n');
writeFileSync(hookScriptPath, hookScript, { mode: 0o755 });

// --- Write settings file with Stop hook config ---

const settings = {
    hooks: {
        Stop: [
            {
                matcher: '',
                hooks: [
                    {
                        type: 'command',
                        command: hookScriptPath
                    }
                ]
            }
        ]
    }
};
writeFileSync(settingsPath, JSON.stringify(settings));

// --- Pre-seed ~/.claude.json to avoid interactive dialogs ---
// Sets hasCompletedOnboarding and trusts the working directory.
// Uses realpathSync to handle macOS /tmp → /private/tmp symlinks.

const ensureNonInteractiveSetup = (cwd) => {
    const claudeJsonPath = join(homedir(), '.claude.json');
    try {
        let data = {};
        try { data = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')); } catch { /* file may not exist */ }

        if (!data.hasCompletedOnboarding) {
            data.hasCompletedOnboarding = true;
            data.lastOnboardingVersion = '9.9.9';
        }

        // Trust both the raw and real cwd paths (handles macOS /tmp symlink)
        const paths = new Set([cwd]);
        try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }

        data.projects = data.projects || {};
        for (const p of paths) {
            if (!data.projects[p]?.hasTrustDialogAccepted) {
                data.projects[p] = {
                    ...(data.projects[p] || {}),
                    hasTrustDialogAccepted: true,
                    projectOnboardingSeenCount: 10
                };
            }
        }

        writeFileSync(claudeJsonPath, JSON.stringify(data));
        log(`Non-interactive setup complete for: ${cwd}`);
    } catch (err) {
        log(`Warning: non-interactive setup failed: ${err.message}`);
    }
};

ensureNonInteractiveSetup(process.cwd());

// --- Build claude args ---

const claudeArgs = [];
if (resumeId) {
    claudeArgs.push('--resume', resumeId);
} else {
    claudeArgs.push('--session-id', sessionId);
}
if (skipPermissions) {
    claudeArgs.push('--dangerously-skip-permissions');
}
if (model) {
    claudeArgs.push('--model', model);
}
claudeArgs.push('--settings', settingsPath);
claudeArgs.push(prompt);

log(`Session: ${effectiveSessionId}`);
log(`Prompt length: ${prompt.length} chars`);

// --- Spawn claude via PTY ---

const ptyProcess = pty.spawn(claudePath, claudeArgs, {
    name: 'xterm-256color',
    cols: 120,
    rows: 40,
    cwd: process.cwd(),
    env: { ...process.env, CLAUBBIT: '1' }
});

log(`PID: ${ptyProcess.pid}`);
writeFileSync(claudePidPath, String(ptyProcess.pid));

// --- Auto-confirm --dangerously-skip-permissions dialog ---
// Claude shows a "Yes/No" dialog when --dangerously-skip-permissions is used.
// Detect it in PTY output and send down-arrow (select "Yes") + Enter.

let ttyBuffer = '';
let permissionConfirmed = false;
ptyProcess.onData((data) => {
    ttyBuffer += data;
    if (!permissionConfirmed && ttyBuffer.includes('Yes') && ttyBuffer.includes('No')) {
        log('Auto-confirming bypass permissions dialog');
        ptyProcess.write('\x1b[B'); // down arrow to select "Yes"
        setTimeout(() => ptyProcess.write('\r'), 200); // Enter
        permissionConfirmed = true;
    }
    // Keep buffer bounded
    if (ttyBuffer.length > 10000) ttyBuffer = ttyBuffer.slice(-5000);
});

// --- Transcript tailing ---
// Claude writes session transcript to ~/.claude/projects/<cwd-hash>/<session-id>.jsonl.
// We poll it every 1 second and forward new entries to stdout as streaming events.

let transcriptPath = null;
let transcriptLinesRead = 0;

// Resolve transcript path, handling macOS /tmp → /private/tmp symlinks.
// Returns null if not yet found (retried on next poll).
const resolveTranscriptPath = () => {
    if (transcriptPath) return transcriptPath;

    const claudeProjectsDir = join(homedir(), '.claude', 'projects');
    const cwd = process.cwd();
    const paths = new Set([cwd]);
    try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }

    for (const dir of paths) {
        const hash = dir.replace(/\//g, '-');
        const candidate = join(claudeProjectsDir, hash, `${effectiveSessionId}.jsonl`);
        if (existsSync(candidate)) {
            transcriptPath = candidate;
            log(`Transcript found: ${transcriptPath}`);
            // On resume: snapshot existing line count so only new content streams
            if (resumeId) {
                try {
                    const content = readFileSync(candidate, 'utf-8');
                    transcriptLinesRead = content.trim().split('\n').filter(Boolean).length;
                    log(`Resume: skipping ${transcriptLinesRead} existing transcript lines`);
                } catch { /* ignore */ }
            }
            return transcriptPath;
        }
    }
    return null;
};

// Read new transcript entries and forward to stdout. Returns count of new lines emitted.
const tailTranscript = () => {
    const path = resolveTranscriptPath();
    if (!path) return 0;

    let newLines = 0;
    try {
        const content = readFileSync(path, 'utf-8');
        const lines = content.trim().split('\n').filter(Boolean);
        for (let i = transcriptLinesRead; i < lines.length; i++) {
            try {
                const entry = JSON.parse(lines[i]);
                // Skip hook progress notifications (internal noise)
                if (entry.type === 'progress' && entry.data?.type === 'hook_progress') continue;
                process.stdout.write(JSON.stringify({ type: 'transcript', entry }) + '\n');
                newLines++;
            } catch {
                // Skip unparseable lines
            }
        }
        transcriptLinesRead = lines.length;
    } catch {
        // File may not exist yet or be mid-write — retry on next poll
    }
    return newLines;
};

// --- Sentinel / result emission ---
// Called from onExit after polling stops. Reads sentinel written by Stop hook.

let resultEmitted = false;

const emitResult = () => {
    if (resultEmitted) return;
    if (!existsSync(sentinelPath)) return;

    try {
        const hookData = JSON.parse(readFileSync(sentinelPath, 'utf-8'));
        const result = {
            type: 'result',
            result: hookData.last_assistant_message || '',
            metadata: {
                sessionId: hookData.session_id || effectiveSessionId,
                transcriptPath: hookData.transcript_path || transcriptPath || null,
                permissionMode: hookData.permission_mode || null
            }
        };
        process.stdout.write(JSON.stringify(result) + '\n');
        resultEmitted = true;
    } catch (err) {
        log(`Error reading sentinel: ${err.message}`);
    }
};

// --- Inactivity timeout ---
// Resets on every new transcript entry. Only fires after 5 continuous minutes
// with NO transcript activity AND no process exit — indicating a genuine hang.

const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
let inactivityTimer = null;

const resetInactivityTimer = () => {
    if (inactivityTimer) clearTimeout(inactivityTimer);
    inactivityTimer = setTimeout(() => {
        log('Inactivity timeout: no transcript activity for 5 minutes, killing claude');
        stopPolling();
        ptyProcess.kill();
        // Clean up temp dir before exiting — process.exit() is synchronous so
        // onExit cannot fire after this point.
        try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
        process.exit(1);
    }, INACTIVITY_TIMEOUT_MS);
};

// --- Transcript poll interval ---

let transcriptPollInterval;

const stopPolling = () => {
    if (inactivityTimer) { clearTimeout(inactivityTimer); inactivityTimer = null; }
    if (transcriptPollInterval) { clearInterval(transcriptPollInterval); transcriptPollInterval = null; }
};

transcriptPollInterval = setInterval(() => {
    const newLines = tailTranscript();
    if (newLines > 0) resetInactivityTimer();
}, 1000);

// Start inactivity timer now that claude is spawned
resetInactivityTimer();

// --- Signal forwarding ---
// Forward SIGTERM/SIGINT to the claude process inside the PTY.
// onExit fires when ptyProcess exits, handling cleanup.

process.on('SIGTERM', () => {
    log('Received SIGTERM, forwarding to claude');
    ptyProcess.kill('SIGTERM');
});

process.on('SIGINT', () => {
    log('Received SIGINT, forwarding to claude');
    ptyProcess.kill('SIGINT');
});

// --- Process exit handler ---

let exited = false;
ptyProcess.onExit(({ exitCode }) => {
    if (exited) return;
    exited = true;

    // Stop polling (sentinelPath still exists on disk at this point)
    stopPolling();

    // Do a final transcript tail to capture any last entries
    tailTranscript();

    // Read sentinel and emit result (tempDir still present — delete after)
    emitResult();

    // Clean up temp directory
    try { rmSync(tempDir, { recursive: true, force: true }); } catch {}

    log(`Claude exited (code: ${exitCode})`);

    // Exit 0 if result was successfully emitted (includes SIGINT 130 remap),
    // or if claude itself exited cleanly. Otherwise propagate exit code.
    if (resultEmitted || exitCode === 0) {
        process.exit(0);
    }
    process.exit(exitCode ?? 1);
});
