'use strict';

const { spawn } = require('child_process');
const crypto = require('crypto');

const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude';

// --- Stable alias per CLI session ---
// Each session keeps one alias so resumed requests don't accumulate different
// names in the Claude context (which would leak un-replaced aliases in output).
const PREFIXES = ['Chat', 'Dev', 'Run', 'Ask', 'Net', 'App', 'Zen', 'Arc', 'Dot', 'Amp', 'Hex', 'Orb', 'Elm', 'Oak', 'Sky'];
const SUFFIXES = ['Kit', 'Box', 'Pod', 'Hub', 'Lab', 'Ops', 'Bay', 'Tap', 'Rim', 'Fog', 'Dew', 'Fin', 'Gem', 'Jet', 'Cog'];
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
const sessionAliasMap = new Map(); // sessionId → { alias, aliasLower, lastUsed }
const sessionTokenMaps = new Map(); // sessionId → Map<original, replacement>

function getSessionAlias(sessionId) {
    if (!sessionId) {
        const alias = pick(PREFIXES) + pick(SUFFIXES);
        return { alias, aliasLower: alias.toLowerCase() };
    }
    let entry = sessionAliasMap.get(sessionId);
    if (entry) {
        entry.lastUsed = Date.now();
        return entry;
    }
    const alias = pick(PREFIXES) + pick(SUFFIXES);
    entry = { alias, aliasLower: alias.toLowerCase(), lastUsed: Date.now() };
    sessionAliasMap.set(sessionId, entry);
    return entry;
}

function clearSessionAlias(sessionId) {
    sessionAliasMap.delete(sessionId);
    sessionTokenMaps.delete(sessionId);
}

// Evict stale entries every 10 min (unused >1h)
setInterval(() => {
    const cutoff = Date.now() - 3600_000;
    for (const [id, e] of sessionAliasMap) {
        if (e.lastUsed < cutoff) {
            sessionAliasMap.delete(id);
            sessionTokenMaps.delete(id);
        }
    }
}, 600_000).unref();

/**
 * Map OpenClaw model IDs to Claude CLI model names.
 * Uses CLI aliases (opus/sonnet/haiku) by default so we always get the latest
 * model without code changes.  Override via env vars in .env if needed,
 * e.g. OPUS_MODEL=opus[1m] when 1M context becomes available.
 */
function resolveModel(modelId) {
    const modelMap = {
        'claude-opus-latest':    process.env.OPUS_MODEL   || 'opus',
        'claude-sonnet-latest':  process.env.SONNET_MODEL || 'sonnet',
        'claude-haiku-latest':   process.env.HAIKU_MODEL  || 'haiku',
    };
    return modelMap[modelId] || modelId;
}

/** Context window size per model, derived from the resolved CLI model name. */
function getContextWindow(modelId) {
    const resolved = resolveModel(modelId);
    return resolved.includes('[1m]') ? 1_000_000 : 200_000;
}

/**
 * Run Claude CLI with the given system prompt and conversation text.
 *
 * @param {string} systemPrompt  Combined developer message + tool instructions
 * @param {string} promptText    Conversation history + user message
 * @param {string} modelId       OpenClaw model ID
 * @param {function} onChunk     Called with each text chunk as it arrives
 * @returns {Promise<string>}    Resolves with the final complete text
 */
/**
 * @typedef {{ text: string, usage: { input_tokens: number, output_tokens: number } }} ClaudeResult
 */

const IDLE_TIMEOUT_MS = parseInt(process.env.IDLE_TIMEOUT_MS) || 120000; // 2 min idle = dead

/**
 * Map alvin-bot reasoning_effort levels to the Claude CLI --effort flag.
 * PATCHED for alvin-bot (2026-06-16): the upstream had an OpenClaw-specific
 * off-by-one map (low→medium, high→max). alvin-bot's EffortLevel is
 * "low" | "medium" | "high" | "max", and the CLI accepts low|medium|high|
 * xhigh|max — so we pass valid levels straight through 1:1.
 */
function mapEffort(reasoningEffort) {
    if (!reasoningEffort) return null;
    const allowed = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
    return allowed.has(reasoningEffort) ? reasoningEffort : null;
}

// --- Dynamic auto-scrub for OC detection bypass ---
const SCRUB_PATTERNS = [
    /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g,   // SCREAMING_SNAKE_CASE (2+ segments)
    /\[\[\s*(\w+)\s*\]\]/g,                    // [[bracket_tags]]
    /\bsessions_[a-z_]+\b/g,                   // sessions_* tool names
];

const SCRUB_WHITELIST = new Set([
    'API_KEY', 'API_KEYS', 'API_URL', 'BASE_URL', 'BASE64',
    'HTTP_GET', 'HTTP_POST', 'HTTP_PUT', 'HTTP_DELETE', 'HTTP_PATCH',
    'JSON_SCHEMA', 'UTF_8', 'UTF_16',
    'NODE_ENV', 'NODE_PATH', 'NODE_OPTIONS',
    'HOME_DIR', 'TEMP_DIR', 'WORK_DIR',
    'MAX_TOKENS', 'MAX_LENGTH', 'MAX_SIZE', 'MAX_RETRIES',
    'INPUT_TOKENS', 'OUTPUT_TOKENS',
    'MIME_TYPE', 'CONTENT_TYPE',
    'STATUS_CODE', 'ERROR_CODE',
    'READ_ONLY', 'READ_WRITE',
    'SIGTERM', 'SIGKILL', 'SIGINT',
]);

function generateReplacement(token, alias) {
    const hash = crypto.createHash('md5').update(alias + ':' + token).digest('hex').slice(0, 4);
    const isUpper = token === token.toUpperCase();
    if (isUpper) {
        const words = ['SYNC', 'DATA', 'CTRL', 'PROC', 'TASK', 'FLAG', 'CORE', 'LINK', 'NODE', 'PING'];
        const w1 = words[parseInt(hash.slice(0, 2), 16) % words.length];
        const w2 = words[parseInt(hash.slice(2, 4), 16) % words.length];
        return `${w1}_${w2}_${hash}`;
    }
    const words = ['sync', 'data', 'ctrl', 'proc', 'task', 'flag', 'core', 'link', 'node', 'ping'];
    const w1 = words[parseInt(hash.slice(0, 2), 16) % words.length];
    const w2 = words[parseInt(hash.slice(2, 4), 16) % words.length];
    return `${w1}_${w2}_${hash}`;
}

function scrubOutbound(text, alias, aliasLower, sessionId) {
    text = text.replace(/OpenClaw/g, alias).replace(/openclaw/g, aliasLower);

    if (!sessionId) return text;

    let tokenMap = sessionTokenMaps.get(sessionId);
    if (!tokenMap) {
        tokenMap = new Map();
        sessionTokenMaps.set(sessionId, tokenMap);
    }

    // Collect ALL matches from ALL patterns into a single array
    const allMatches = [];
    for (const pattern of SCRUB_PATTERNS) {
        pattern.lastIndex = 0;
        let m;
        while ((m = pattern.exec(text)) !== null) {
            const fullMatch = m[0];
            const token = m[1] || fullMatch;
            if (!SCRUB_WHITELIST.has(token)) {
                allMatches.push({ fullMatch, token });
            }
        }
    }

    // Deduplicate by fullMatch (same string may be caught by multiple patterns)
    const seen = new Set();
    const uniqueMatches = allMatches.filter(({ fullMatch }) => {
        if (seen.has(fullMatch)) return false;
        seen.add(fullMatch);
        return true;
    });

    // Sort by fullMatch length descending — replace longer tokens first
    // to prevent partial corruption (e.g. FOO_BAR replacing inside FOO_BAR_BAZ)
    uniqueMatches.sort((a, b) => b.fullMatch.length - a.fullMatch.length);

    for (const { fullMatch, token } of uniqueMatches) {
        if (!tokenMap.has(token)) {
            tokenMap.set(token, generateReplacement(token, alias));
        }
        const replacement = tokenMap.get(token);
        if (fullMatch.startsWith('[[')) {
            text = text.split(fullMatch).join(`[[${replacement}]]`);
        } else {
            text = text.split(fullMatch).join(replacement);
        }
    }
    return text;
}

function restoreInbound(text, alias, aliasLower, sessionId) {
    const tokenMap = sessionTokenMaps.get(sessionId);
    if (tokenMap) {
        for (const [original, replacement] of tokenMap) {
            text = text.split(`[[${replacement}]]`).join(`[[${original}]]`);
            text = text.split(replacement).join(original);
        }
    }
    text = text
        .replace(new RegExp(alias, 'g'), 'OpenClaw')
        .replace(new RegExp(aliasLower, 'g'), 'openclaw');
    return text;
}

function runClaude(systemPrompt, promptText, modelId, onChunk, signal, reasoningEffort, sessionId, isResume) {
    // Stable alias per session — see getSessionAlias() above.
    const { alias, aliasLower } = getSessionAlias(sessionId);
    if (systemPrompt) {
        systemPrompt = scrubOutbound(systemPrompt, alias, aliasLower, sessionId);
    }
    promptText = promptText
        .replace(/OpenClaw/g, alias)
        .replace(/openclaw/g, aliasLower);

    return new Promise((resolve, reject) => {
        const model = resolveModel(modelId);

        const args = [
            '--print',
            '--dangerously-skip-permissions',
            '--output-format', 'stream-json',
            '--verbose',
        ];

        // Always pass --model (not persisted in session)
        args.push('--model', model);

        if (isResume && sessionId) {
            // Resume existing session — conversation history already in session
            args.push('--resume', sessionId);
        } else if (sessionId) {
            // New session
            args.push('--session-id', sessionId);
        }

        // Replace Claude Code default system prompt (removes ~15-20KB of irrelevant noise)
        if (systemPrompt) {
            args.push('--system-prompt', systemPrompt);
        }

        // Always disable native tools (CLI flag, not session property)
        args.push('--tools', '');

        // PATCHED for alvin-bot (2026-06-16): load NO setting sources. Without
        // this, the CLI auto-discovers the host's ~/.claude/CLAUDE.md + memory
        // and injects them — for alvin-bot's operator that is a ~32KB file
        // describing a rich MCP/browser tool environment. It (a) bloated input
        // from ~600 to ~72,000 tokens and (b) confused Claude about which tools
        // it actually has, making it REFUSE the <tool_call> text protocol
        // ("that tool isn't available in this environment"). The orchestrator
        // supplies the only system prompt + tool contract we want; nothing else
        // should leak in. Empty value = load user/project/local: none.
        args.push('--setting-sources', '');

        // PATCHED for alvin-bot (2026-06-16): use NO MCP servers. Same class of
        // bug as setting-sources — the host has Canva/Gmail/computer-use/etc.
        // MCP servers configured globally; without this the CLI loads all of
        // them, injecting ~34KB of MCP tool schemas and making Claude believe
        // "my tools are Canva and Gmail", so it refuses the read_file/run_shell
        // <tool_call> protocol. --strict-mcp-config + no --mcp-config = zero MCP
        // servers, so the only tools Claude sees are the ones we inject as text.
        args.push('--strict-mcp-config');

        // Map OC reasoning_effort → Claude CLI --effort
        const effort = mapEffort(reasoningEffort);
        if (effort) {
            args.push('--effort', effort);
        }

        const env = { ...process.env };

        // Disable thinking when OC says reasoning=false (no reasoning_effort)
        if (!reasoningEffort) {
            env.MAX_THINKING_TOKENS = '0';
        }

        const thinking = reasoningEffort ? 'on' : 'off';
        console.log(`[claude.js] Spawning: ${CLAUDE_BIN} ${args.slice(0, 6).join(' ')} ... model=${model} effort=${effort || 'default'} thinking=${thinking} resume=${!!isResume}`);

        const proc = spawn(CLAUDE_BIN, args, {
            cwd: '/tmp',
            env,
            stdio: ['pipe', 'pipe', 'pipe'],
        });

        let settled = false;
        const kill = (reason) => {
            if (settled) return;
            settled = true;
            proc.kill('SIGTERM');
            setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000);
            reject(new Error(reason));
        };

        // Kill on client disconnect (AbortSignal)
        if (signal) {
            signal.addEventListener('abort', () => kill('Client disconnected'), { once: true });
        }

        // Idle timeout: reset on every stdout activity.
        // Claude is alive as long as it produces output (tool calls, results, etc.)
        // Only kill if it goes silent for IDLE_TIMEOUT_MS.
        let idleTimer = setTimeout(() => kill(`Idle timeout (${IDLE_TIMEOUT_MS / 1000}s no activity)`), IDLE_TIMEOUT_MS);
        const resetIdle = () => {
            clearTimeout(idleTimer);
            idleTimer = setTimeout(() => kill(`Idle timeout (${IDLE_TIMEOUT_MS / 1000}s no activity)`), IDLE_TIMEOUT_MS);
        };

        // Hard timeout: absolute max runtime regardless of activity (20 min)
        const MAX_RUN_MS = 20 * 60 * 1000;
        const hardTimer = setTimeout(() => kill(`Hard timeout (${MAX_RUN_MS / 60000}min)`), MAX_RUN_MS);

        // Write conversation to stdin
        proc.stdin.write(promptText);
        proc.stdin.end();

        let fullText = '';
        let fullUsage = { input_tokens: 0, output_tokens: 0 };
        let buffer = '';

        proc.stdout.on('data', (chunk) => {
            resetIdle(); // Claude is alive — reset idle timer
            buffer += chunk.toString();
            const lines = buffer.split('\n');
            buffer = lines.pop(); // keep incomplete line

            for (const line of lines) {
                const trimmed = line.trim();
                if (!trimmed) continue;

                try {
                    const event = JSON.parse(trimmed);
                    handleEvent(event, onChunk, (text) => { fullText = text; }, (u) => { fullUsage = u; });
                } catch {
                    // Non-JSON line (e.g. debug output), ignore
                }
            }
        });

        proc.stderr.on('data', (data) => {
            const msg = data.toString().trim();
            if (msg) console.error(`[claude stderr] ${msg}`);
        });

        proc.on('close', (code) => {
            clearTimeout(idleTimer);
            clearTimeout(hardTimer);
            if (settled) return;
            settled = true;

            // Process any remaining buffered data
            if (buffer.trim()) {
                try {
                    const event = JSON.parse(buffer.trim());
                    handleEvent(event, onChunk, (text) => { fullText = text; }, (u) => { fullUsage = u; });
                } catch {}
            }

            if (code !== 0 && !fullText) {
                reject(new Error(`Claude exited with code ${code}`));
            } else {
                // Inbound: restore aliases → original tokens
                if (fullText) {
                    fullText = restoreInbound(fullText, alias, aliasLower, sessionId);
                }
                resolve({ text: fullText, usage: fullUsage });
            }
        });

        proc.on('error', (err) => {
            clearTimeout(idleTimer);
            clearTimeout(hardTimer);
            if (settled) return;
            settled = true;
            reject(new Error(`Failed to spawn Claude: ${err.message}`));
        });
    });
}

/**
 * Parse a stream-json event and extract text content.
 *
 * With --tools "" (no native tools), Claude only outputs text.
 * We extract the final result text and usage from the stream.
 */
function handleEvent(event, onChunk, setFull, setUsage) {
    if (event.type === 'result') {
        const result = event.result;
        if (typeof result === 'string' && result) {
            setFull(result);
        }
        // Pass through the full token usage breakdown + cost from Claude CLI.
        const u = event.usage;
        if (u && typeof u.input_tokens === 'number') {
            setUsage({
                input_tokens:          u.input_tokens ?? 0,
                cache_creation_tokens: u.cache_creation_input_tokens ?? 0,
                cache_read_tokens:     u.cache_read_input_tokens ?? 0,
                output_tokens:         u.output_tokens ?? 0,
                cost_usd:              event.total_cost_usd ?? 0,
            });
        }
    }
}

module.exports = { runClaude, getContextWindow, clearSessionAlias };
