/**
 * Sequential dispatcher for 1인 기업 모드.
 *
 * Drives one company "turn":
 *
 *   user prompt
 *      → CEO planner (JSON {brief, tasks})
 *      → for each task in plan: dispatch one specialist (sequentially)
 *           - build specialist prompt (incl. peer context from earlier agents)
 *           - call the AI service
 *           - persist its output to disk
 *           - append its output to the peer-context buffer for the next agent
 *      → CEO reporter (synthesis markdown)
 *      → persist `_report.md`, update agent memory + decisions
 *      → emit `companyTurnUpdate` events to the webview at each phase
 *
 * Why sequential? The user runs Astra on a single GPU/CPU with limited RAM,
 * and parallel agents would force us to keep multiple models loaded
 * simultaneously. Sequential dispatch keeps "exactly one model resident at
 * a time" — the LM Studio lifecycle manager unloads the previous model and
 * loads the next when an agent has its own override.
 *
 * Why not use `AgentExecutor.handlePrompt` here? Because `handlePrompt` is
 * built for the *interactive* chat path: it owns the conversation history,
 * streaming UI, agent-mode injection, and a dozen other things we don't
 * want triggered by a company turn. The company dispatcher needs a clean
 * "one system + one user → one string back" primitive — `AIService.chat()`
 * fits that perfectly. Specialists can still emit action tags
 * (`<create_file>`, `<run_command>`); we route their *raw* output through
 * the existing action-tag executor afterwards so file/command tools work
 * exactly as in chat.
 */
import * as vscode from 'vscode';
import { IAIService } from '../../core/services';
import { getActiveBrainProfile, logError, logInfo } from '../../utils';
import { retrieveScoped, buildContextBlock } from '../../skills/scopedBrainRetriever';
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
import {
    mapWeightToBrainFileLimit,
    buildKnowledgeMixPolicy,
} from '../../retrieval/knowledgeMix';
import {
    modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix,
} from './companyConfig';
import { runCeoPlanner } from './ceoPlanner';
import { runCeoReporter } from './ceoReporter';
import { buildSpecialistPrompt } from './promptBuilder';
import {
    appendAgentMemory,
    appendDecision,
    createSessionDir,
    newSessionTimestamp,
    readAgentMemory,
    readDecisions,
    writeAgentOutput,
    writeBrief,
    writeReport,
    writeSessionJson,
} from './sessionStore';
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
import { AgentTurnOutput, CompanyState, CompanyTaskPlan, PipelineDef, PipelineStage, SessionResult } from './types';

/** Trim length applied when an agent's output is fed into the next agent. */
const PEER_OUTPUT_BUDGET = 1500;

/**
 * Events emitted during a turn. The sidebar webview subscribes to render
 * progress (chips, headers, streamed agent replies). The shape is generic so
 * the same channel can carry CEO/agent/report messages without per-type
 * postMessage plumbing.
 */
/**
 * User's decision after a stage with `requiresApproval=true` finishes.
 *   - 'approve'   → proceed to next stage as-is
 *   - 'revise'    → re-run the same stage; comment is prepended to its instruction
 *   - 'abort'     → end the turn (same as hitting Stop)
 */
export type ApprovalDecision =
    | { kind: 'approve' }
    | { kind: 'revise'; comment: string }
    | { kind: 'abort' };

export type CompanyTurnEvent =
    | { phase: 'plan-start' }
    | { phase: 'plan-ready'; plan: CompanyTaskPlan; parsed: boolean; raw: string }
    | { phase: 'agent-start'; agentId: string; task: string; index: number; total: number }
    | { phase: 'agent-done'; agentId: string; output: AgentTurnOutput; index: number; total: number }
    /**
     * Pipeline-mode only: emitted when a stage's output matches the
     * configured `loopBackPattern` and the dispatcher jumps back to a
     * previous stage. The webview uses this to render "🔁 stage X → Y
     * (재시도 N차)" in the chat.
     */
    | { phase: 'stage-loop'; from: string; to: string; iteration: number }
    /**
     * Manual approval gate. Emitted right before the dispatcher awaits the
     * user's decision. Webview surfaces 승인/수정요청/중단 buttons.
     */
    | { phase: 'awaiting-approval'; stageId: string; stageLabel: string; index: number; total: number }
    /** Resolved approval — purely informational for the chat log. */
    | { phase: 'approval-resolved'; stageId: string; decision: 'approve' | 'revise' | 'abort' }
    | { phase: 'report-start' }
    | { phase: 'report-done'; report: string; ok: boolean }
    /**
     * Emitted after the secretary attempts to mirror the CEO report to
     * Telegram. `ok=true` ⇒ delivered. `ok=false` ⇒ delivery failed (network
     * / API error). `ok=null` ⇒ no mirror was attempted because the user
     * hasn't opted in (no token, no chat id, or `telegram.enabled=false`).
     */
    | { phase: 'telegram-mirror'; ok: boolean | null; reason?: string }
    | { phase: 'session-saved'; sessionDir: string }
    | { phase: 'aborted'; reason: string };

export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;

export interface DispatcherDeps {
    context: vscode.ExtensionContext;
    ai: IAIService;
    /** Default model to fall back to when an agent has no override. */
    defaultModel: string;
    /**
     * Global Knowledge Mix weight (0–100) — fallback when an agent has no
     * per-agent override. Mirrors `g1nation.knowledgeMix.secondBrainWeight`.
     */
    globalKnowledgeMixWeight: number;
    /**
     * Baseline number of brain files to retrieve at weight=50 (balanced).
     * The actual count is `mapWeightToBrainFileLimit(weight, baseline)`.
     * Pass the same value the chat path uses (`config.memoryLongTermFiles`)
     * so company-mode behaviour stays in sync.
     */
    brainFileBaseline?: number;
    /**
     * Apply ConnectAI's action-tag executor to the specialist's raw response.
     * Without this hook, agent outputs containing `<create_file>` etc. would
     * be shown to the user as a *claim* of file creation but nothing would
     * actually land on disk. We thread it through deps (rather than
     * importing AgentExecutor directly) so the dispatcher stays free of
     * sidebar / agent.ts dependencies for unit testability.
     */
    executeActionTags?: (text: string) => Promise<string[]>;
    /** Per-call cancellation. The sidebar's Stop button flips this. */
    signal?: AbortSignal;
    /** Optional event sink for the webview. Receives events synchronously. */
    onEvent?: CompanyTurnEmitter;
    /**
     * Manual-approval bridge. When a pipeline stage has `requiresApproval`,
     * the dispatcher emits `phase: 'awaiting-approval'` and then *awaits*
     * this promise. The host (SidebarChatProvider) is responsible for:
     *   1. Storing a resolver tied to the emitted stageId
     *   2. Surfacing approval buttons in the webview chat
     *   3. Resolving the promise when the user clicks one of them
     *   4. Resolving with `{ kind: 'abort' }` if the turn is cancelled
     *      (so the dispatcher doesn't hang forever)
     */
    awaitApproval?: (ctx: { stageId: string; stageLabel: string }) => Promise<ApprovalDecision>;
}

/**
 * Run a single company turn. Returns a fully-populated `SessionResult` even
 * on partial failure (so callers can always render *something* in chat).
 */
export async function runCompanyTurn(
    userPrompt: string,
    deps: DispatcherDeps,
): Promise<SessionResult> {
    const startedAt = Date.now();
    const state = readCompanyState(deps.context);
    const timestamp = newSessionTimestamp();
    const sessionDir = createSessionDir(deps.context, timestamp);

    const emit: CompanyTurnEmitter = deps.onEvent ?? (() => { /* noop */ });
    const isAborted = () => deps.signal?.aborted === true;
    const fail = (reason: string): SessionResult => {
        emit({ phase: 'aborted', reason });
        return {
            timestamp, sessionDir,
            userPrompt,
            plan: { brief: '', tasks: [] },
            agentOutputs: [],
            report: '',
            totalDurationMs: Date.now() - startedAt,
        };
    };
    if (isAborted()) return fail('signal-aborted');

    // ── Phase 1: plan (pipeline or legacy planner) ──
    emit({ phase: 'plan-start' });
    const pipeline = resolveActivePipeline(state);
    let plan: CompanyTaskPlan;
    let plannerRaw = '';
    let plannerParsed = false;
    if (pipeline) {
        // Pipeline mode: the user has authored a fixed sequence of stages.
        // We still surface a `plan` for the report writer and the session
        // summary — derived directly from the pipeline definition.
        plan = {
            brief: `[Pipeline: ${pipeline.name}] ${userPrompt.slice(0, 200)}`,
            tasks: pipeline.stages.map((s) => ({ agent: s.agentId, task: s.label })),
        };
        plannerParsed = true;
    } else {
        const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
        const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel });
        plan = plannerResult.plan;
        plannerRaw = plannerResult.raw;
        plannerParsed = plannerResult.parsed;
    }
    if (isAborted()) return fail('aborted-after-plan');
    emit({
        phase: 'plan-ready',
        plan,
        parsed: plannerParsed,
        raw: plannerRaw,
    });
    writeBrief(sessionDir, userPrompt, plan);

    // ── Phase 2: sequential dispatch ──
    const outputs: AgentTurnOutput[] = [];
    if (pipeline) {
        const runResult = await _runPipeline(pipeline, userPrompt, plan.brief, sessionDir, timestamp, state, deps, isAborted, emit);
        if (runResult.aborted) return fail(runResult.aborted);
        outputs.push(...runResult.outputs);
    } else {
        const total = plan.tasks.length;
        for (let i = 0; i < total; i++) {
            if (isAborted()) return fail('aborted-mid-dispatch');
            const task = plan.tasks[i];
            emit({ phase: 'agent-start', agentId: task.agent, task: task.task, index: i, total });
            const turn = await _dispatchOne(task.agent, task.task, outputs, state, deps);
            outputs.push(turn);
            writeAgentOutput(sessionDir, turn);
            appendAgentMemory(
                deps.context,
                task.agent,
                `[${timestamp}] ${task.task} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
            );
            emit({ phase: 'agent-done', agentId: task.agent, output: turn, index: i, total });
        }
    }

    // ── Phase 3: synthesis ──
    if (isAborted()) return fail('aborted-before-report');
    emit({ phase: 'report-start' });
    const reportModel = modelForAgent(state, 'ceo', deps.defaultModel);
    const reportResult = await runCeoReporter(
        deps.ai,
        plan,
        outputs,
        state,
        { model: reportModel },
    );
    writeReport(sessionDir, reportResult.report);
    emit({ phase: 'report-done', report: reportResult.report, ok: reportResult.ok });

    // ── Phase 3.5: Secretary mirror to Telegram ──
    // Origin parity (Connect_origin extension.ts ~line 20620): after the CEO
    // synthesizes the round, the Secretary (영숙) ships a digest to the user's
    // Telegram chat. Strictly best-effort — failures never break the turn.
    let telegramOk: boolean | null = null;
    let telegramReason: string | undefined;
    try {
        const reporter = await buildTelegramReporter(deps.context);
        if (!reporter) {
            telegramReason = 'no-config'; // opt-in off, no token, or no chat id
        } else {
            const tgText = formatCompanyTelegramReport({
                state,
                userPrompt,
                plan,
                outputs,
                report: reportResult.report,
                sessionTimestamp: timestamp,
                sessionDir,
            });
            telegramOk = await reporter(tgText);
            if (!telegramOk) telegramReason = 'delivery-failed';
        }
    } catch (e: any) {
        telegramOk = false;
        telegramReason = e?.message ?? String(e);
        logError('company.dispatcher: telegram mirror threw.', { error: telegramReason });
    }
    emit({ phase: 'telegram-mirror', ok: telegramOk, reason: telegramReason });

    // ── Phase 4: persist + side effects ──
    const result: SessionResult = {
        timestamp, sessionDir,
        userPrompt,
        plan,
        agentOutputs: outputs,
        report: reportResult.report,
        totalDurationMs: Date.now() - startedAt,
    };
    writeSessionJson(sessionDir, result);
    // Heuristic: if the report mentions a 🚀 line, extract it as a decision.
    const decisionLine = reportResult.report.split(/\n/).find((l) => /^\d+\.\s+/.test(l.trim()));
    if (decisionLine) appendDecision(deps.context, decisionLine.trim());
    emit({ phase: 'session-saved', sessionDir });

    logInfo('company.dispatcher: turn complete.', {
        sessionDir, agents: outputs.length, ok: reportResult.ok,
        durationMs: result.totalDurationMs,
    });
    return result;
}

/**
 * Dispatch one specialist. Wraps the AI call with try/catch so a single
 * agent's failure never aborts the whole turn — we record the error and
 * keep going so the user still gets the other agents' outputs.
 */
async function _dispatchOne(
    agentId: string,
    task: string,
    earlierOutputs: AgentTurnOutput[],
    state: ReturnType<typeof readCompanyState>,
    deps: DispatcherDeps,
    /** Pipeline stage override — wins over the agent's own model override. */
    stageModelOverride?: string,
): Promise<AgentTurnOutput> {
    const startedAt = Date.now();
    const def = resolveAgent(state, agentId);
    if (!def) {
        return {
            agentId, task, response: '', durationMs: 0,
            error: `Unknown agent id: ${agentId}`,
        };
    }
    const memory = readAgentMemory(deps.context, agentId);
    const decisions = readDecisions(deps.context, 2000);
    const peerOutputs = earlierOutputs
        .filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent
        .map((o) => {
            const peerDef = resolveAgent(state, o.agentId);
            const body = o.response.length > PEER_OUTPUT_BUDGET
                ? o.response.slice(0, PEER_OUTPUT_BUDGET) + '\n…(truncated)'
                : o.response;
            return {
                agentId: o.agentId,
                agentName: peerDef?.name ?? o.agentId,
                emoji: peerDef?.emoji ?? '🤖',
                content: body,
            };
        });

    // ── Second Brain RAG for this specialist ────────────────────────────────
    // The non-company chat path uses `AgentExecutor.buildMemoryContext` to
    // pull RAG chunks before every LLM call. The dispatcher used to skip
    // that entirely, leaving company agents *blind* to the user's stored
    // knowledge — which made the Knowledge Mix slider effectively a no-op
    // for company turns. We now run a lightweight scoped retrieval here so
    // every dispatch sees the same brain the user expects, weighted by the
    // agent's own Knowledge Mix.
    const { weight: knowledgeWeight, source: knowledgeMixSource } =
        resolveCompanyKnowledgeMix(state, agentId, deps.globalKnowledgeMixWeight);
    const brainFileLimit = mapWeightToBrainFileLimit(knowledgeWeight, deps.brainFileBaseline ?? 6);
    let brainContext = '';
    if (brainFileLimit > 0) {
        try {
            const brain = getActiveBrainProfile();
            const brainRoot = brain?.localBrainPath || '';
            if (brainRoot) {
                // Reuse the agent ↔ knowledge map: if the same agent name
                // appears there (free-form .md path or canonical id), we
                // honour its `knowledgeFolders` scope. Otherwise we search
                // the whole brain so a missing mapping doesn't starve the
                // dispatcher.
                const scope = resolveScopeForAgent(agentId, brainRoot);
                const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
                    maxResults: brainFileLimit,
                });
                brainContext = buildContextBlock(retrieval);
            }
        } catch (e: any) {
            logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', {
                agentId, error: e?.message ?? String(e),
            });
        }
    }
    const policyBlock = buildKnowledgeMixPolicy({
        weight: knowledgeWeight,
        source: knowledgeMixSource,
        agent: def.name,
    });

    const system = buildSpecialistPrompt({
        agentId, state,
        agentMemory: memory, sharedDecisions: decisions,
        peerOutputs,
        brainContext,                          // injected as `[SECOND BRAIN CONTEXT]` block
        knowledgeMixPolicy: policyBlock,       // injected as `[KNOWLEDGE MIX POLICY]` block
    });
    // 우선순위: stage > agent > global default.
    const model = (stageModelOverride && stageModelOverride.trim())
        ? stageModelOverride.trim()
        : modelForAgent(state, agentId, deps.defaultModel);

    try {
        const result = await deps.ai.chat({
            system,
            user: task,
            model,
        });
        const rawResponse = (result.content || '').trim();
        // Apply ConnectAI's action-tag executor so `<create_file>`,
        // `<run_command>`, `<edit_file>`, etc. emitted by the agent actually
        // hit disk / shell. The report (e.g. "✅ Created: foo.py") is
        // appended to the response so the user sees what really happened.
        let finalResponse = rawResponse || '_(empty response)_';
        let actionReport: string[] | undefined;
        const hasTag = !!rawResponse && _hasActionTag(rawResponse);
        if (rawResponse && deps.executeActionTags && hasTag) {
            try {
                const report = await deps.executeActionTags(rawResponse);
                actionReport = report;
                if (report.length > 0) {
                    finalResponse = `${rawResponse}\n\n---\n**Action 실행 결과:**\n${report.map((r) => `- ${r}`).join('\n')}`;
                }
            } catch (e: any) {
                // Surface the failure but keep the agent's text — partial
                // success is more useful than dropping the whole response.
                const err = e?.message ?? String(e);
                logError('company.dispatcher: action-tag execution failed.', { agentId, err });
                finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`;
            }
        } else if (rawResponse && !hasTag && _claimsFileCreation(rawResponse)) {
            // Hallucination guard: small models love to *narrate* file
            // creation ("foo.py를 생성했습니다 …") without emitting the
            // <create_file> tag — so the user sees ✅ in chat but nothing
            // on disk. Catch the mismatch here and flag it loudly so the
            // CEO synthesis (which reads this response) and the user both
            // know nothing was actually written.
            const warning = '⚠️ **실제 파일이 생성되지 않았습니다.** Agent가 파일 생성을 텍스트로 설명했지만 ConnectAI 액션 태그(`<create_file>` 등)를 사용하지 않아 디스크에 아무것도 만들어지지 않았어요. 같은 요청을 다시 시도하거나, 사용자가 직접 만드세요.';
            finalResponse = `${rawResponse}\n\n---\n${warning}`;
            logInfo('company.dispatcher: agent claimed creation without action tag.', { agentId });
        }
        // `error: 'no-action-tag-but-claimed'` is *advisory* — we still let
        // the turn complete because some agents (Writer, Researcher) are
        // legitimately answer-only. But by flagging the agent output we
        // mark it as not-fully-successful so the CEO synthesis can read
        // the warning verbatim.
        const claimedButDidnt = rawResponse && !hasTag && _claimsFileCreation(rawResponse);
        return {
            agentId, task,
            response: finalResponse,
            durationMs: Date.now() - startedAt,
            error: rawResponse
                ? (claimedButDidnt ? 'claimed-creation-no-tag' : undefined)
                : 'empty-response',
            actionReport,
        };
    } catch (e: any) {
        const err = e?.message ?? String(e);
        logError('company.dispatcher: agent dispatch failed.', { agentId, err });
        return {
            agentId, task,
            response: `⚠️ 호출 실패: ${err}`,
            durationMs: Date.now() - startedAt,
            error: err,
        };
    }
}

/**
 * Run an authored pipeline: each stage dispatches its agent with a templated
 * instruction. Stages can declare a `loopBackPattern` regex — when it
 * matches the stage's output, the dispatcher jumps back to `loopBackTo` (a
 * stage that must precede the current one). Iteration count is bounded by
 * `maxIterations` (default 3) to keep run-away loops from hanging the user.
 *
 * Returns `{ outputs, aborted }`: `aborted` is set only when the abort
 * signal flipped mid-run; the outer dispatcher then short-circuits.
 */
async function _runPipeline(
    pipeline: PipelineDef,
    userPrompt: string,
    brief: string,
    sessionDir: string,
    timestamp: string,
    state: CompanyState,
    deps: DispatcherDeps,
    isAborted: () => boolean,
    emit: CompanyTurnEmitter,
): Promise<{ outputs: AgentTurnOutput[]; aborted?: string }> {
    const outputs: AgentTurnOutput[] = [];
    // Keep the latest output per stage id so `{{stage.<id>}}` template
    // tokens always resolve to the most recent value across loop-backs.
    const latestByStage: Record<string, AgentTurnOutput> = {};
    const iterations: Record<string, number> = {};
    const total = pipeline.stages.length;
    // Per-stage extra instruction injected by user revision requests. Cleared
    // after the stage re-runs successfully so it doesn't pollute the rest of
    // the pipeline.
    const revisionNotes: Record<string, string> = {};
    let i = 0;
    let stepIndex = 0;
    while (i < pipeline.stages.length) {
        if (isAborted()) return { outputs, aborted: 'aborted-mid-pipeline' };
        const stage = pipeline.stages[i];
        const baseTask = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
        const note = revisionNotes[stage.id];
        const task = note
            ? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
            : baseTask;
        emit({ phase: 'agent-start', agentId: stage.agentId, task, index: stepIndex, total });
        const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps, stage.modelOverride);
        outputs.push(turn);
        latestByStage[stage.id] = turn;
        writeAgentOutput(sessionDir, turn);
        appendAgentMemory(
            deps.context, stage.agentId,
            `[${timestamp}][${pipeline.id}/${stage.id}] ${task.slice(0, 120)} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
        );
        emit({ phase: 'agent-done', agentId: stage.agentId, output: turn, index: stepIndex, total });
        stepIndex++;
        // Successful run consumed the revision note (if any) — clear it.
        if (!turn.error) delete revisionNotes[stage.id];

        // ── Manual approval gate ──
        // After agent-done emits, before loop-back / next stage advance,
        // give the user a chance to inspect and approve. We only fire the
        // gate when (a) the stage opted in, (b) we have an awaitApproval
        // bridge from the host, and (c) the stage didn't error out
        // (errored stages would loop forever waiting for "approval" of a
        // failure — the user should just hit Stop).
        if (stage.requiresApproval && deps.awaitApproval && !turn.error) {
            emit({
                phase: 'awaiting-approval',
                stageId: stage.id,
                stageLabel: stage.label || stage.id,
                index: stepIndex - 1,
                total,
            });
            let decision: ApprovalDecision;
            try {
                decision = await deps.awaitApproval({ stageId: stage.id, stageLabel: stage.label || stage.id });
            } catch {
                // 호스트가 에러를 던지면 안전하게 중단 — 무한 대기 방지.
                decision = { kind: 'abort' };
            }
            if (isAborted()) return { outputs, aborted: 'aborted-mid-approval' };
            emit({ phase: 'approval-resolved', stageId: stage.id, decision: decision.kind });
            if (decision.kind === 'abort') {
                return { outputs, aborted: 'aborted-by-user-at-approval' };
            }
            if (decision.kind === 'revise') {
                revisionNotes[stage.id] = decision.comment || '(추가 코멘트 없음)';
                // 같은 stage 재실행 — i를 그대로 두고 continue.
                continue;
            }
            // 'approve' → 아래 loop-back/다음 stage 진행 로직으로 자연히 fall-through.
        }

        // Loop-back evaluation. We only loop on *successful* responses with
        // non-empty body — an error or empty response would loop forever.
        if (stage.loopBackTo && stage.loopBackPattern && !turn.error && turn.response.trim()) {
            const limit = stage.maxIterations ?? 3;
            const count = (iterations[stage.id] ?? 0) + 1;
            iterations[stage.id] = count;
            let re: RegExp | null = null;
            try { re = new RegExp(stage.loopBackPattern, 'i'); } catch { re = null; }
            if (re && re.test(turn.response) && count <= limit) {
                const targetIdx = pipeline.stages.findIndex((s) => s.id === stage.loopBackTo);
                if (targetIdx !== -1 && targetIdx < i) {
                    emit({ phase: 'stage-loop', from: stage.id, to: stage.loopBackTo, iteration: count });
                    i = targetIdx;
                    continue;
                }
            }
        }
        i++;
    }
    return { outputs };
}

/**
 * Substitute template tokens in a stage's instruction. Falls back to the
 * raw user prompt when the template is empty so the user doesn't have to
 * fill every stage with a long template just to forward the original ask.
 */
function _renderStageInstruction(
    stage: PipelineStage,
    userPrompt: string,
    brief: string,
    latestByStage: Record<string, AgentTurnOutput>,
): string {
    const tpl = (stage.instructionTemplate || '').trim();
    if (!tpl) return userPrompt;
    return tpl
        .replace(/\{\{\s*userPrompt\s*\}\}/g, userPrompt)
        .replace(/\{\{\s*brief\s*\}\}/g, brief)
        .replace(/\{\{\s*stage\.([a-zA-Z0-9_-]+)\s*\}\}/g, (_m, sid) => {
            const o = latestByStage[sid];
            return o?.response ?? `[stage:${sid} 아직 실행되지 않음]`;
        });
}

/**
 * Cheap pre-check so we don't fire up the action-tag executor for every
 * specialist response — only the ones that actually contain a recognised
 * tag. Saves a workspace lookup + transaction-manager spin-up on the common
 * case (the agent just talks).
 */
function _hasActionTag(text: string): boolean {
    return /<\s*(?:create_file|edit_file|delete_file|read_file|list_files|list_brain|run_command|read_brain|reveal_in_explorer|open_file|glob|grep)\b/i.test(text);
}

/**
 * Heuristic: does the response *narrate* having created files/folders?
 *
 * We look for the combination of (a) a Korean / English creation verb and
 * (b) a filename-like or "folder" mention. The intent is to catch the
 * hallucination pattern where an agent writes "foo.py 파일을 생성했습니다"
 * or "Created `bar/` directory" without emitting the corresponding
 * `<create_file>` tag, so the dispatcher can flag it back to the CEO and
 * the user instead of silently reporting success.
 *
 * Kept narrow on purpose — a *plan* like "다음에는 X를 만들어야 합니다"
 * shouldn't trigger this. We require past-tense / completion phrasing.
 */
function _claimsFileCreation(text: string): boolean {
    // Past-tense creation verbs (Korean + English).
    const claimRe = /(?:생성했|만들었|작성했|저장했|구현했|created|wrote|saved|built|generated)/i;
    if (!claimRe.test(text)) return false;
    // Combined with either an explicit filename (something.ext) or the word
    // "폴더" / "directory" / "folder" near the verb.
    const fileLike = /\b[\w\-./]+\.(?:py|js|ts|tsx|jsx|md|json|html|css|sh|yaml|yml|sql|java|go|rs|c|cpp|rb|php)\b/i.test(text);
    const folderLike = /(?:폴더|디렉토리|directory|folder)/i.test(text);
    return fileLike || folderLike;
}
