/**
 * CEO planner — turns a user prompt into a `CompanyTaskPlan`.
 *
 * Lifecycle of one planner call:
 *   1. Build the planner system prompt (template + active-agent list).
 *   2. Hit the AI service with the user prompt as the user message.
 *   3. Parse the response through a 4-stage JSON pipeline that tolerates
 *      ```json fences, leading thoughts, truncated outputs, and minor key
 *      misspellings. Smaller local models violate "no extra text" rules
 *      *constantly*, so a permissive parser is required.
 *   4. Normalize agent ids: accept Korean nicknames (`레오` → `youtube`,
 *      `코다리` → `developer`) and filter out tasks for inactive agents.
 *
 * The function never throws — it always returns a `CompanyTaskPlan`. If
 * everything fails we surface an empty plan with a brief that explains what
 * happened, and the dispatcher treats that as "nothing to dispatch, just
 * relay the chat-style reply".
 */
import { IAIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
import { isAgentActive, listAllAgents, resolveAgent } from './companyConfig';
import { applyPromptVars, CEO_PLANNER_PROMPT } from './promptAssets';
import { buildPlannerSystemPrompt } from './promptBuilder';
import { CompanyState, CompanyTaskPlan } from './types';

export interface PlannerResult {
    plan: CompanyTaskPlan;
    /** True iff JSON parsing succeeded — false means we fell back to empty. */
    parsed: boolean;
    /** Raw LLM output (kept for the chat / debug log). */
    raw: string;
}

const EMPTY_PLAN: CompanyTaskPlan = { brief: '', tasks: [] };

/**
 * Build a nickname → id map from a state snapshot. Built-ins + custom
 * agents are merged so a user-added agent's name can match the planner's
 * output too. Rebuilt per normalize call (cheap — <50 entries).
 */
function _buildNameMap(state: CompanyState): Record<string, string> {
    const out: Record<string, string> = {};
    for (const def of listAllAgents(state)) {
        out[def.id.toLowerCase()] = def.id;
        out[def.name.toLowerCase()] = def.id;
        const roleHead = def.role.split(/[\s·]+/)[0]?.toLowerCase();
        if (roleHead && !out[roleHead]) out[roleHead] = def.id;
    }
    return out;
}

function _canonicalAgentId(raw: unknown, state: CompanyState, nameMap: Record<string, string>): string | null {
    if (typeof raw !== 'string') return null;
    const key = raw.trim().toLowerCase();
    return nameMap[key] ?? (resolveAgent(state, key) ? key : null);
}

/**
 * 4-stage JSON extractor — same idea as Connect_origin's planner but built
 * fresh here so we don't carry over its 21K-line file. Each stage is a fall-
 * through: we keep trying until something gives us a parseable object.
 */
function _parsePlanJson(raw: string): CompanyTaskPlan | null {
    if (!raw || !raw.trim()) return null;

    // Stage 1 — strip ```json … ``` fence + leading "okay let me think" prose.
    const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
    const stage1 = (fenced ? fenced[1] : raw).trim();

    // Stage 2 — direct JSON.parse.
    try {
        const obj = JSON.parse(stage1);
        const plan = _coercePlan(obj);
        if (plan) return plan;
    } catch { /* fall through */ }

    // Stage 3 — find the first balanced `{ … }` and parse just that. Smaller
    // models love to prepend explanations or append trailing notes.
    const balanced = _extractFirstBalancedObject(stage1);
    if (balanced) {
        try {
            const obj = JSON.parse(balanced);
            const plan = _coercePlan(obj);
            if (plan) return plan;
        } catch { /* fall through */ }
    }

    // Stage 4 — regex recovery. If JSON is truncated mid-task we still try
    // to pull `brief` + any complete `{agent, task}` pairs from the text.
    const briefMatch = stage1.match(/"brief"\s*:\s*"([\s\S]*?)"/);
    const brief = briefMatch ? briefMatch[1] : '';
    const tasks: CompanyTaskPlan['tasks'] = [];
    const taskRe = /\{\s*"agent"\s*:\s*"([^"]+)"\s*,\s*"task"\s*:\s*"([\s\S]*?)"\s*\}/g;
    let m: RegExpExecArray | null;
    while ((m = taskRe.exec(stage1))) {
        tasks.push({ agent: m[1].trim(), task: m[2].trim() });
    }
    if (brief || tasks.length > 0) return { brief: brief.trim(), tasks };
    return null;
}

function _coercePlan(obj: unknown): CompanyTaskPlan | null {
    if (!obj || typeof obj !== 'object') return null;
    const o = obj as Record<string, unknown>;
    const brief = typeof o.brief === 'string' ? o.brief : '';
    const rawTasks = Array.isArray(o.tasks) ? o.tasks : [];
    const tasks: CompanyTaskPlan['tasks'] = [];
    for (const t of rawTasks) {
        if (!t || typeof t !== 'object') continue;
        const tt = t as Record<string, unknown>;
        if (typeof tt.agent === 'string' && typeof tt.task === 'string') {
            tasks.push({ agent: tt.agent.trim(), task: tt.task.trim() });
        }
    }
    return { brief: brief.trim(), tasks };
}

/** Find the first complete `{ … }` block respecting brace nesting. */
function _extractFirstBalancedObject(s: string): string | null {
    const start = s.indexOf('{');
    if (start === -1) return null;
    let depth = 0;
    let inString = false;
    let escape = false;
    for (let i = start; i < s.length; i++) {
        const ch = s[i];
        if (inString) {
            if (escape) escape = false;
            else if (ch === '\\') escape = true;
            else if (ch === '"') inString = false;
            continue;
        }
        if (ch === '"') { inString = true; continue; }
        if (ch === '{') depth++;
        else if (ch === '}') {
            depth--;
            if (depth === 0) return s.slice(start, i + 1);
        }
    }
    return null;
}

/**
 * Filter + normalize a freshly-parsed plan against the current company
 * state. Tasks targeting unknown / inactive agents are dropped, and Korean
 * nicknames are rewritten to canonical ids.
 */
/**
 * Post-hoc 직군 가드. The LLM is supposed to follow the "planner before
 * developer" rule encoded in the planner prompt, but small models break it
 * routinely. We re-check the parsed plan and inject a planner task at the
 * front when build/design tasks were scheduled without any prior planning
 * step. Returns the original plan when no fix is needed.
 *
 * This is best-effort, not authoritative — for a strict guarantee, the user
 * should activate a pipeline (which is deterministic). The fallback agent
 * for the auto-inserted planner step is the highest-priority active
 * planner; if no planner direction is active we leave the plan as-is so
 * the user isn't silently force-fed into a workflow they didn't set up.
 */
function _applyRoleGuard(
    plan: CompanyTaskPlan,
    state: CompanyState,
    userPrompt: string,
): CompanyTaskPlan {
    if (plan.tasks.length === 0) return plan;
    const roleOf = (agentId: string): string => resolveAgent(state, agentId)?.roleCategory ?? 'support';
    // 가드: 첫 build/design task 이전에 planner / researcher / inspector step이 하나도 없다.
    const buildIdx = plan.tasks.findIndex((t) => {
        const r = roleOf(t.agent);
        return r === 'developer' || r === 'designer';
    });
    if (buildIdx === -1) return plan; // build/design task 없음 → 가드 불필요
    const hasPriorContext = plan.tasks.slice(0, buildIdx).some((t) => {
        const r = roleOf(t.agent);
        return r === 'planner' || r === 'researcher' || r === 'inspector';
    });
    if (hasPriorContext) return plan; // 이미 사전 정리 task 존재
    // 사용자가 명시적으로 "기획 없이 바로", "지금 당장 코드만" 라고 했다면 가드 skip
    if (/(기획\s*없이|바로\s*코드|기획\s*X|간단히|빨리)/i.test(userPrompt)) return plan;
    // planner 직군의 첫 활성 에이전트를 골라 정리 task 한 줄 prepend.
    const plannerAgent = listAllAgents(state)
        .find((a) => a.roleCategory === 'planner' && (a.id === 'ceo' || state.activeAgentIds.includes(a.id)));
    if (!plannerAgent) return plan; // planner 활성 없음 → 강제 못 함
    const inserted = {
        agent: plannerAgent.id,
        task: `사용자 요청을 한 번 정리한 뒤 다음 단계로 넘기세요. 요청: "${userPrompt.slice(0, 200)}". 다음 사항을 한 단락으로 정리: (1) 무엇을 만드는가, (2) 누가 쓰는가, (3) 성공 기준 1~2개.`,
    };
    logInfo('ceoPlanner: role guard inserted planner task.', { agent: plannerAgent.id });
    return { brief: plan.brief, tasks: [inserted, ...plan.tasks] };
}

export function normalizePlan(plan: CompanyTaskPlan, state: CompanyState): CompanyTaskPlan {
    const out: CompanyTaskPlan = { brief: plan.brief, tasks: [] };
    const dropped: string[] = [];
    const nameMap = _buildNameMap(state);
    for (const t of plan.tasks) {
        const canonical = _canonicalAgentId(t.agent, state, nameMap);
        if (!canonical) {
            dropped.push(`unknown:${t.agent}`);
            continue;
        }
        if (canonical === 'ceo') {
            // CEO is the orchestrator — it never receives a task in `tasks`
            // (the report phase calls it separately). Drop silently.
            dropped.push('ceo:self-dispatch');
            continue;
        }
        if (!isAgentActive(state, canonical)) {
            dropped.push(`inactive:${canonical}`);
            continue;
        }
        out.tasks.push({ agent: canonical, task: t.task });
    }
    if (dropped.length > 0) {
        logInfo('ceoPlanner: dropped tasks during normalization.', { dropped });
    }
    return out;
}

/**
 * Run the CEO planner end-to-end. Never throws. The caller decides what to
 * do with `{ parsed: false, plan: { tasks: [] } }` — usually we surface the
 * raw text as a casual CEO reply.
 */
export async function runCeoPlanner(
    ai: IAIService,
    userPrompt: string,
    state: CompanyState,
    options: { model?: string; timeoutMs?: number; contractBlock?: string; signal?: AbortSignal } = {},
): Promise<PlannerResult> {
    const baseSystem = applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName });
    // Contract가 있으면 planner 시스템 프롬프트 끝에 prepend. planner는 task
    // 리스트를 JSON으로 뽑으므로 contract를 보고 *적절한* task만 만들 수 있다.
    const systemWithContract = options.contractBlock && options.contractBlock.trim()
        ? `${baseSystem}\n\n${options.contractBlock.trim()}\n\n위 contract가 모든 dispatch 결정의 ground truth입니다.`
        : baseSystem;
    const system = buildPlannerSystemPrompt(systemWithContract, state);
    let raw = '';
    try {
        const result = await ai.chat({
            system,
            user: userPrompt,
            model: options.model,
            timeoutMs: options.timeoutMs,
            signal: options.signal,
        });
        raw = result.content || '';
    } catch (e: any) {
        logError('ceoPlanner: AI call failed.', { error: e?.message ?? String(e) });
        return { plan: EMPTY_PLAN, parsed: false, raw: '' };
    }

    const parsed = _parsePlanJson(raw);
    if (!parsed) {
        // No JSON found — treat as a casual chat reply. The dispatcher's
        // empty-plan branch will surface `raw` as the CEO's spoken response.
        return { plan: { brief: raw.trim(), tasks: [] }, parsed: false, raw };
    }

    const normalized = normalizePlan(parsed, state);
    const plan = _applyRoleGuard(normalized, state, userPrompt);
    logInfo('ceoPlanner: parsed plan.', {
        briefChars: plan.brief.length,
        taskCount: plan.tasks.length,
        agents: plan.tasks.map((t) => t.agent),
        roleGuardApplied: plan.tasks.length !== normalized.tasks.length,
    });
    return { plan, parsed: true, raw };
}
