/**
 * State + config plumbing for 1인 기업 모드.
 *
 * Two surfaces:
 *
 *   - **`CompanyState`** (runtime data: enabled flag, company name, which
 *     agents are active, per-agent model overrides). Persisted in VS Code's
 *     `globalState` so it survives reloads. Mutating it always goes through
 *     `update*()` helpers so the webview can re-render after the change.
 *
 *   - **Read-only helpers** that derive useful data from the current state +
 *     the static `COMPANY_AGENTS` roster (active list, model-for-agent lookup,
 *     etc.). Keeping these in one module means the planner, dispatcher, and
 *     UI all consult one place.
 *
 * The choice of `globalState` over `workspaceState` is deliberate: the user
 * wants the same company / agent set / nicknames available across every
 * project they open. Per-workspace overrides can be added later as a layer
 * on top without breaking this API.
 */
import * as vscode from 'vscode';
import { COMPANY_AGENTS, DEFAULT_ACTIVE_AGENTS, getCompanyAgent } from './agents';
import {
    AgentDisplayOverride, AgentPromptOverride, AgentRoleCategory, CompanyAgentDef,
    CompanyState, COMPANY_STATE_KEY, PipelineDef, PipelineStage, ROLE_CATEGORY_ORDER,
} from './types';

const VALID_ROLE_CATEGORIES = new Set<AgentRoleCategory>(ROLE_CATEGORY_ORDER);

function _coerceRoleCategory(raw: unknown, fallback: AgentRoleCategory): AgentRoleCategory {
    if (typeof raw === 'string' && VALID_ROLE_CATEGORIES.has(raw as AgentRoleCategory)) {
        return raw as AgentRoleCategory;
    }
    return fallback;
}

/**
 * Validation for a user-supplied agent id. Lowercase ASCII, digits, hyphens,
 * underscores. Built-in ids cannot be reused (would shadow code). Empty /
 * invalid input returns an error string for the UI to surface.
 */
export function validateCustomAgentId(id: string): { ok: true } | { ok: false; reason: string } {
    const trimmed = (id || '').trim();
    if (!trimmed) return { ok: false, reason: 'id가 비어 있습니다.' };
    if (!/^[a-z][a-z0-9_-]{1,40}$/.test(trimmed)) {
        return { ok: false, reason: 'id는 소문자/숫자/-/_ 만 허용 (2~41자, 소문자 시작).' };
    }
    if (COMPANY_AGENTS[trimmed]) {
        return { ok: false, reason: `'${trimmed}'은 기본 에이전트 id와 겹칩니다.` };
    }
    return { ok: true };
}

function _normalizeCustomAgentDef(raw: unknown): CompanyAgentDef | null {
    if (!raw || typeof raw !== 'object') return null;
    const r = raw as Record<string, unknown>;
    const id = typeof r.id === 'string' ? r.id.trim() : '';
    const name = typeof r.name === 'string' ? r.name.trim() : '';
    const role = typeof r.role === 'string' ? r.role.trim() : '';
    if (!id || !name || !role) return null;
    if (validateCustomAgentId(id).ok !== true) return null;
    return {
        id,
        name,
        role,
        emoji: typeof r.emoji === 'string' && r.emoji.trim() ? r.emoji.trim() : '🤖',
        color: typeof r.color === 'string' && /^#?[0-9a-fA-F]{3,8}$/.test(r.color.trim())
            ? (r.color.trim().startsWith('#') ? r.color.trim() : '#' + r.color.trim())
            : '#94A3B8',
        specialty: typeof r.specialty === 'string' ? r.specialty.trim() : '',
        tagline: typeof r.tagline === 'string' ? r.tagline.trim() : '',
        persona: typeof r.persona === 'string' && r.persona.trim() ? r.persona.trim() : undefined,
        // CEO 직군은 빌트인 전용. 사용자가 ceo로 만들려 해도 'support'로 fallback.
        roleCategory: (() => {
            const rc = _coerceRoleCategory(r.roleCategory, 'support');
            return rc === 'ceo' ? 'support' : rc;
        })(),
        // `alwaysOn` is reserved for built-ins (CEO). Custom agents never set it.
        alwaysOn: false,
    };
}

/** Default state for a brand-new user. CEO is always on. */
function _defaultState(): CompanyState {
    return {
        enabled: false,
        companyName: '1인 기업',
        activeAgentIds: DEFAULT_ACTIVE_AGENTS.slice(),
        modelOverrides: {},
        promptOverrides: {},
        knowledgeMixOverrides: {},
        customAgents: {},
        roleCategoryOverrides: {},
        displayOverrides: {},
        pipelines: {},
        activePipelineId: null,
        hiddenBuiltinIds: [],
    };
}

/**
 * Validation for a pipeline / stage id. Same alphabet rules as agent ids
 * so the UI feels consistent. Empty / invalid input returns an error
 * string for the UI to surface.
 */
function _validId(id: string): boolean {
    return /^[a-z][a-z0-9_-]{1,40}$/.test((id || '').trim());
}

function _normalizeStage(raw: unknown): PipelineStage | null {
    if (!raw || typeof raw !== 'object') return null;
    const r = raw as Record<string, unknown>;
    const id = typeof r.id === 'string' ? r.id.trim() : '';
    const agentId = typeof r.agentId === 'string' ? r.agentId.trim() : '';
    const roleCategory = typeof r.roleCategory === 'string'
        && VALID_ROLE_CATEGORIES.has(r.roleCategory as AgentRoleCategory)
        ? (r.roleCategory as string)
        : '';
    const label = typeof r.label === 'string' && r.label.trim() ? r.label.trim() : id;
    if (!_validId(id)) return null;
    // agentId 또는 roleCategory 둘 중 하나는 반드시 있어야 한다.
    // 둘 다 없으면 dispatcher가 누구를 부를지 알 길이 없어 stage가 의미 없음.
    if (!agentId && !roleCategory) return null;
    const out: PipelineStage = {
        id, label,
        instructionTemplate: typeof r.instructionTemplate === 'string' ? r.instructionTemplate : '',
    };
    if (agentId) out.agentId = agentId;
    if (roleCategory) out.roleCategory = roleCategory;
    if (typeof r.modelOverride === 'string' && r.modelOverride.trim()) {
        out.modelOverride = r.modelOverride.trim();
    }
    if (r.requiresApproval === true) {
        out.requiresApproval = true;
    }
    if (typeof r.reviewWith === 'string' && r.reviewWith.trim()) {
        // 'inspector' / 'role:<cat>' / 'agent:<id>' 형태만 허용. 그 외는 무시.
        const rv = r.reviewWith.trim();
        const isInspectorShort = rv === 'inspector';
        const isRolePrefix = rv.startsWith('role:') && VALID_ROLE_CATEGORIES.has(rv.slice(5) as AgentRoleCategory);
        const isAgentPrefix = rv.startsWith('agent:') && _validId(rv.slice(6));
        if (isInspectorShort || isRolePrefix || isAgentPrefix) {
            out.reviewWith = rv;
        }
    }
    if (typeof r.reviewMaxRounds === 'number' && Number.isFinite(r.reviewMaxRounds)) {
        out.reviewMaxRounds = Math.max(1, Math.min(10, Math.round(r.reviewMaxRounds)));
    }
    if (typeof r.loopBackPattern === 'string' && r.loopBackPattern.trim()) {
        out.loopBackPattern = r.loopBackPattern.trim();
    }
    if (typeof r.loopBackTo === 'string' && _validId(r.loopBackTo)) {
        out.loopBackTo = r.loopBackTo.trim();
    }
    if (typeof r.maxIterations === 'number' && Number.isFinite(r.maxIterations)) {
        out.maxIterations = Math.max(1, Math.min(10, Math.round(r.maxIterations)));
    }
    return out;
}

function _normalizePipeline(raw: unknown): PipelineDef | null {
    if (!raw || typeof raw !== 'object') return null;
    const r = raw as Record<string, unknown>;
    const id = typeof r.id === 'string' ? r.id.trim() : '';
    const name = typeof r.name === 'string' && r.name.trim() ? r.name.trim() : id;
    if (!_validId(id)) return null;
    const rawStages = Array.isArray(r.stages) ? r.stages : [];
    const stages: PipelineStage[] = [];
    const seen = new Set<string>();
    for (const s of rawStages) {
        const ns = _normalizeStage(s);
        if (!ns) continue;
        if (seen.has(ns.id)) continue; // stage ids must be unique within pipeline
        seen.add(ns.id);
        stages.push(ns);
    }
    // Drop loopBackTo references that point to a non-existent / later stage.
    for (let i = 0; i < stages.length; i++) {
        const s = stages[i];
        if (s.loopBackTo) {
            const earlierIdx = stages.findIndex((x) => x.id === s.loopBackTo);
            if (earlierIdx === -1 || earlierIdx >= i) {
                delete s.loopBackTo;
                delete s.loopBackPattern;
            }
        }
    }
    return { id, name, stages };
}

/**
 * Normalize a state value loaded from globalState. Guards against schema
 * drift (e.g. unknown agent ids that no longer exist, missing fields).
 */
function _normalize(raw: Partial<CompanyState> | undefined): CompanyState {
    const def = _defaultState();
    if (!raw || typeof raw !== 'object') return def;
    const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : def.enabled;
    const companyName = typeof raw.companyName === 'string' && raw.companyName.trim()
        ? raw.companyName.trim()
        : def.companyName;
    // Custom agents come first so the merged-id check below is one-pass.
    const customAgents: Record<string, CompanyAgentDef> = {};
    if (raw.customAgents && typeof raw.customAgents === 'object') {
        for (const [id, ad] of Object.entries(raw.customAgents as Record<string, unknown>)) {
            const def = _normalizeCustomAgentDef(ad);
            // Force id consistency: the record key wins over a mismatched .id
            if (def && def.id === id) customAgents[id] = def;
        }
    }
    const knownId = (id: string): boolean =>
        !!getCompanyAgent(id) || Object.prototype.hasOwnProperty.call(customAgents, id);
    const validIds = Array.isArray(raw.activeAgentIds)
        ? raw.activeAgentIds.filter((id): id is string => typeof id === 'string' && knownId(id))
        : def.activeAgentIds;
    // CEO is *implicitly* always active — keep it out of the persisted list
    // so we never accidentally drop it, but the public reader re-includes it.
    const withoutCeo = validIds.filter((id) => id !== 'ceo');
    const overrides: Record<string, string> = {};
    if (raw.modelOverrides && typeof raw.modelOverrides === 'object') {
        for (const [k, v] of Object.entries(raw.modelOverrides)) {
            if (typeof v === 'string' && v.trim() && knownId(k)) {
                overrides[k] = v.trim();
            }
        }
    }
    // Prompt overrides — drop anything for an unknown agent, and prune empty
    // sub-fields so we never persist `{persona: ""}` (resolveAgentPrompt
    // treats empty same as missing, but cleaner state stays portable).
    const promptOverrides: Record<string, AgentPromptOverride> = {};
    if (raw.promptOverrides && typeof raw.promptOverrides === 'object') {
        for (const [agentId, v] of Object.entries(raw.promptOverrides as Record<string, unknown>)) {
            if (!knownId(agentId) || !v || typeof v !== 'object') continue;
            const ov = v as Record<string, unknown>;
            const cleaned: AgentPromptOverride = {};
            if (typeof ov.persona === 'string' && ov.persona.trim()) cleaned.persona = ov.persona.trim();
            if (typeof ov.specialty === 'string' && ov.specialty.trim()) cleaned.specialty = ov.specialty.trim();
            if (typeof ov.tagline === 'string' && ov.tagline.trim()) cleaned.tagline = ov.tagline.trim();
            if (cleaned.persona || cleaned.specialty || cleaned.tagline) {
                promptOverrides[agentId] = cleaned;
            }
        }
    }
    // Knowledge-mix overrides — validate that values are integers in [0,100]
    // and the agent id is a known one. Anything else is dropped silently so
    // a hand-edited globalState doesn't put garbage into the resolver.
    const knowledgeMixOverrides: Record<string, number> = {};
    if (raw.knowledgeMixOverrides && typeof raw.knowledgeMixOverrides === 'object') {
        for (const [agentId, v] of Object.entries(raw.knowledgeMixOverrides as Record<string, unknown>)) {
            if (!knownId(agentId)) continue;
            if (typeof v === 'number' && Number.isFinite(v)) {
                const w = Math.max(0, Math.min(100, Math.round(v)));
                knowledgeMixOverrides[agentId] = w;
            }
        }
    }
    // 직군 override — 사용자가 빌트인을 reclassify할 수 있게.
    // 'ceo'로 설정하려는 시도는 무시 (지휘자 직군은 빌트인 CEO 전용).
    const roleCategoryOverrides: Record<string, AgentRoleCategory> = {};
    if (raw.roleCategoryOverrides && typeof raw.roleCategoryOverrides === 'object') {
        for (const [agentId, v] of Object.entries(raw.roleCategoryOverrides as Record<string, unknown>)) {
            if (!knownId(agentId) || agentId === 'ceo') continue;
            if (typeof v !== 'string' || !VALID_ROLE_CATEGORIES.has(v as AgentRoleCategory)) continue;
            if (v === 'ceo') continue;
            roleCategoryOverrides[agentId] = v as AgentRoleCategory;
        }
    }
    // 디스플레이 override — 이름·역할·이모지·색상 사용자 편집. 빈 필드는
    // pruning, 알 수 없는 agent id는 drop.
    const displayOverrides: Record<string, AgentDisplayOverride> = {};
    if (raw.displayOverrides && typeof raw.displayOverrides === 'object') {
        for (const [agentId, v] of Object.entries(raw.displayOverrides as Record<string, unknown>)) {
            if (!knownId(agentId) || !v || typeof v !== 'object') continue;
            const ov = v as Record<string, unknown>;
            const cleaned: AgentDisplayOverride = {};
            if (typeof ov.name === 'string' && ov.name.trim()) cleaned.name = ov.name.trim();
            if (typeof ov.role === 'string' && ov.role.trim()) cleaned.role = ov.role.trim();
            if (typeof ov.emoji === 'string' && ov.emoji.trim()) cleaned.emoji = ov.emoji.trim();
            if (typeof ov.color === 'string' && /^#?[0-9a-fA-F]{3,8}$/.test(ov.color.trim())) {
                const c = ov.color.trim();
                cleaned.color = c.startsWith('#') ? c : '#' + c;
            }
            if (cleaned.name || cleaned.role || cleaned.emoji || cleaned.color) {
                displayOverrides[agentId] = cleaned;
            }
        }
    }
    // Pipelines — drop malformed entries; stage agent ids that don't resolve
    // are kept (the dispatcher will surface a per-stage error) so the user
    // can fix them in the editor instead of losing their pipeline silently.
    const pipelines: Record<string, PipelineDef> = {};
    if (raw.pipelines && typeof raw.pipelines === 'object') {
        for (const [pid, p] of Object.entries(raw.pipelines as Record<string, unknown>)) {
            const np = _normalizePipeline(p);
            if (np && np.id === pid) pipelines[pid] = np;
        }
    }
    const activePipelineId = typeof raw.activePipelineId === 'string'
        && Object.prototype.hasOwnProperty.call(pipelines, raw.activePipelineId)
        ? raw.activePipelineId
        : null;
    // 사용자가 "삭제"한 빌트인 에이전트 목록. CEO는 절대 hide할 수 없고, 모르는 id는
    // drop (예: 코드에서 사라진 옛 에이전트). pipelines 가드는 remove 시점에 적용되므로
    // 여기서는 별도 정합성 검사를 하지 않음.
    const hiddenBuiltinIds = Array.isArray(raw.hiddenBuiltinIds)
        ? raw.hiddenBuiltinIds.filter((id): id is string =>
            typeof id === 'string' && id !== 'ceo' && !!getCompanyAgent(id))
        : [];
    return {
        enabled, companyName,
        activeAgentIds: withoutCeo,
        modelOverrides: overrides,
        promptOverrides,
        knowledgeMixOverrides,
        customAgents,
        roleCategoryOverrides,
        displayOverrides,
        pipelines,
        activePipelineId,
        hiddenBuiltinIds,
    };
}

/** Read the current company state. Always returns a fully-populated object. */
export function readCompanyState(context: vscode.ExtensionContext): CompanyState {
    const raw = context.globalState.get<Partial<CompanyState>>(COMPANY_STATE_KEY);
    return _normalize(raw);
}

/** Persist a complete state object. Callers usually go through the `update*`
 *  helpers below; direct use is fine when you want to write multiple fields
 *  atomically. */
export async function writeCompanyState(
    context: vscode.ExtensionContext,
    next: CompanyState,
): Promise<void> {
    await context.globalState.update(COMPANY_STATE_KEY, _normalize(next));
}

/**
 * Toggle the whole mode on/off. Returns the new state so callers can
 * immediately broadcast it to the webview without a re-read.
 */
export async function setCompanyEnabled(
    context: vscode.ExtensionContext,
    enabled: boolean,
): Promise<CompanyState> {
    const cur = readCompanyState(context);
    const next: CompanyState = { ...cur, enabled };
    await writeCompanyState(context, next);
    return next;
}

/** Rename the company. Empty / whitespace input falls back to the default. */
export async function setCompanyName(
    context: vscode.ExtensionContext,
    name: string,
): Promise<CompanyState> {
    const cur = readCompanyState(context);
    const trimmed = (name || '').trim();
    const next: CompanyState = { ...cur, companyName: trimmed || '1인 기업' };
    await writeCompanyState(context, next);
    return next;
}

/** Replace the active-agent set. Order is preserved; unknown ids are dropped. */
export async function setActiveAgents(
    context: vscode.ExtensionContext,
    ids: string[],
): Promise<CompanyState> {
    const cur = readCompanyState(context);
    const next: CompanyState = { ...cur, activeAgentIds: ids };
    await writeCompanyState(context, next);
    return next;
}

/**
 * Set / clear a per-agent model override. Passing empty string removes the
 * override (the agent will fall back to the global default).
 */
export async function setAgentModelOverride(
    context: vscode.ExtensionContext,
    agentId: string,
    model: string,
): Promise<CompanyState> {
    const cur = readCompanyState(context);
    const overrides = { ...cur.modelOverrides };
    if (model && model.trim()) {
        overrides[agentId] = model.trim();
    } else {
        delete overrides[agentId];
    }
    const next: CompanyState = { ...cur, modelOverrides: overrides };
    await writeCompanyState(context, next);
    return next;
}

/**
 * Set / clear a per-agent prompt override. Pass `null` (or an empty
 * `AgentPromptOverride`) to drop the override entirely; pass a partial
 * object to update just the named fields while keeping the others.
 *
 * Field semantics:
 *   - non-empty string → save as the new override
 *   - empty string / null / undefined → *clear* that sub-field
 * This makes "save just persona" and "reset just specialty" symmetric: send
 * the new persona text and an empty string for specialty.
 */
export async function setAgentPromptOverride(
    context: vscode.ExtensionContext,
    agentId: string,
    override: AgentPromptOverride | null,
): Promise<CompanyState> {
    const cur = readCompanyState(context);
    const overrides = { ...cur.promptOverrides };
    if (!override) {
        delete overrides[agentId];
    } else {
        const existing: AgentPromptOverride = { ...(overrides[agentId] ?? {}) };
        for (const key of ['persona', 'specialty', 'tagline'] as const) {
            const v = override[key];
            if (v === undefined) continue; // not specified → leave as-is
            if (typeof v === 'string' && v.trim()) {
                existing[key] = v.trim();
            } else {
                delete existing[key];
            }
        }
        if (existing.persona || existing.specialty || existing.tagline) {
            overrides[agentId] = existing;
        } else {
            delete overrides[agentId];
        }
    }
    const next: CompanyState = { ...cur, promptOverrides: overrides };
    await writeCompanyState(context, next);
    return next;
}

/**
 * Set / clear a per-agent Knowledge Mix override. Pass `null` to revert
 * the agent back to the global default. Anything outside `[0, 100]` is
 * clamped — sliders that overshoot don't corrupt persistence.
 */
export async function setAgentKnowledgeMix(
    context: vscode.ExtensionContext,
    agentId: string,
    weight: number | null,
): Promise<CompanyState> {
    const cur = readCompanyState(context);
    const overrides = { ...cur.knowledgeMixOverrides };
    if (weight === null || weight === undefined || !Number.isFinite(weight)) {
        delete overrides[agentId];
    } else {
        overrides[agentId] = Math.max(0, Math.min(100, Math.round(weight)));
    }
    const next: CompanyState = { ...cur, knowledgeMixOverrides: overrides };
    await writeCompanyState(context, next);
    return next;
}

// ── Custom agent CRUD ──────────────────────────────────────────────────────

/**
 * Add or replace a user-defined agent. Returns either the new state on
 * success, or an error object so the UI can surface validation failures
 * (id collision with built-ins, malformed id, etc.). Accepts `unknown`
 * because the payload comes off the postMessage channel — `_normalizeCustomAgentDef`
 * is responsible for shape validation; this wrapper only adds the id-collision
 * guard and persists the merged state.
 */
export async function addCustomAgent(
    context: vscode.ExtensionContext,
    raw: unknown,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
    const def = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
    const idCheck = validateCustomAgentId(typeof def.id === 'string' ? def.id : '');
    if (idCheck.ok !== true) return { ok: false, reason: idCheck.reason };
    const normalized = _normalizeCustomAgentDef(def);
    if (!normalized) return { ok: false, reason: '에이전트 정의가 유효하지 않습니다 (id · 이름 · 역할 필수).' };
    const cur = readCompanyState(context);
    const customAgents = { ...(cur.customAgents ?? {}), [normalized.id]: normalized };
    const next: CompanyState = { ...cur, customAgents };
    await writeCompanyState(context, next);
    return { ok: true, state: next };
}

/**
 * Remove a user-defined agent and its overrides. Built-in agents cannot be
 * removed — returns `ok:false` instead. The active-agent list is also
 * cleaned so the removed id doesn't linger as a dangling reference.
 */
export async function removeCustomAgent(
    context: vscode.ExtensionContext,
    agentId: string,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
    return removeCompanyAgent(context, agentId);
}

/**
 * Delete *any* agent (built-in or custom). Built-ins can't actually be removed
 * from the code, so we add them to `hiddenBuiltinIds` — the manage panel and
 * dispatcher filters both honour that list. Custom agents are removed outright
 * along with their overrides.
 *
 * Guards:
 *   - CEO은 절대 삭제 불가 — 회사 의사결정자 직군이라 항상 필요.
 *   - 어떤 파이프라인 stage라도 이 에이전트를 참조하면 거부. 사용자가 먼저
 *     파이프라인에서 빼거나 다른 에이전트로 교체해야 함. 메시지에 사용 중인
 *     파이프라인 이름을 그대로 적어 돌려준다 (사용자가 어디로 가서 고쳐야
 *     하는지 바로 보이도록).
 */
export async function removeCompanyAgent(
    context: vscode.ExtensionContext,
    agentId: string,
): Promise<{ ok: true; state: CompanyState; kind: 'custom-removed' | 'builtin-hidden' } | { ok: false; reason: string }> {
    if (!agentId) {
        return { ok: false, reason: '에이전트 ID가 비어 있습니다.' };
    }
    if (agentId === 'ceo') {
        return { ok: false, reason: 'CEO는 회사 의사결정자 직군이라 삭제할 수 없습니다.' };
    }
    const cur = readCompanyState(context);
    const isBuiltin = !!COMPANY_AGENTS[agentId];
    const isCustom = !!cur.customAgents?.[agentId];
    if (!isBuiltin && !isCustom) {
        return { ok: false, reason: `'${agentId}' 에이전트를 찾을 수 없습니다.` };
    }
    // 파이프라인 사용 검사 — 어디든 한 곳이라도 stage.agentId === agentId면 거부.
    const usedIn: string[] = [];
    for (const p of Object.values(cur.pipelines ?? {})) {
        if (p.stages.some((s) => s.agentId === agentId)) {
            usedIn.push(p.name || p.id);
        }
    }
    if (usedIn.length > 0) {
        const list = usedIn.map((n) => `'${n}'`).join(', ');
        return {
            ok: false,
            reason: `다음 워크 파이프라인에서 사용 중이라 삭제할 수 없습니다: ${list}. 먼저 파이프라인에서 이 에이전트를 빼거나 다른 에이전트로 교체한 뒤 다시 시도하세요.`,
        };
    }
    // 공통 override cleanup — 두 경로 모두 동일.
    const { [agentId]: _m, ...modelOverrides } = cur.modelOverrides;
    const { [agentId]: _p, ...promptOverrides } = cur.promptOverrides;
    const { [agentId]: _k, ...knowledgeMixOverrides } = cur.knowledgeMixOverrides;
    const { [agentId]: _r, ...roleCategoryOverrides } = (cur.roleCategoryOverrides ?? {});
    const { [agentId]: _d, ...displayOverrides } = (cur.displayOverrides ?? {});
    const activeAgentIds = cur.activeAgentIds.filter((id) => id !== agentId);

    if (isCustom) {
        const { [agentId]: _gone, ...customAgents } = (cur.customAgents ?? {});
        const next: CompanyState = {
            ...cur, customAgents, modelOverrides, promptOverrides, knowledgeMixOverrides,
            roleCategoryOverrides, displayOverrides, activeAgentIds,
        };
        await writeCompanyState(context, next);
        return { ok: true, state: next, kind: 'custom-removed' };
    }
    // Built-in: add to hiddenBuiltinIds (idempotent) and drop overrides.
    const hidden = new Set(cur.hiddenBuiltinIds ?? []);
    hidden.add(agentId);
    const next: CompanyState = {
        ...cur, modelOverrides, promptOverrides, knowledgeMixOverrides,
        roleCategoryOverrides, displayOverrides, activeAgentIds,
        hiddenBuiltinIds: Array.from(hidden),
    };
    await writeCompanyState(context, next);
    return { ok: true, state: next, kind: 'builtin-hidden' };
}

/**
 * Restore a previously-hidden built-in agent back into the manage panel.
 * No-op if the id wasn't hidden. Custom agents have no "restore" — once
 * deleted they're gone and must be re-created via the add form.
 */
export async function restoreHiddenAgent(
    context: vscode.ExtensionContext,
    agentId: string,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
    if (!agentId || !COMPANY_AGENTS[agentId]) {
        return { ok: false, reason: `'${agentId}'은(는) 빌트인 에이전트가 아닙니다.` };
    }
    const cur = readCompanyState(context);
    const hidden = (cur.hiddenBuiltinIds ?? []).filter((id) => id !== agentId);
    const next: CompanyState = { ...cur, hiddenBuiltinIds: hidden };
    await writeCompanyState(context, next);
    return { ok: true, state: next };
}

/**
 * Set / clear an agent's display-identity override (name / role / emoji /
 * color). Each field follows the same semantics as `setAgentPromptOverride`:
 *   - non-empty string → save as override
 *   - empty string / null / undefined → *clear* that sub-field
 * Passing `null` for the whole override clears every field at once.
 * Built-in CEO can be renamed too — there's no reason to lock the chip text.
 */
export async function setAgentDisplayOverride(
    context: vscode.ExtensionContext,
    agentId: string,
    override: AgentDisplayOverride | null,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
    const cur = readCompanyState(context);
    if (!getCompanyAgent(agentId) && !cur.customAgents?.[agentId]) {
        return { ok: false, reason: `'${agentId}' 에이전트를 찾을 수 없습니다.` };
    }
    const overrides = { ...(cur.displayOverrides ?? {}) };
    if (!override) {
        delete overrides[agentId];
    } else {
        const existing: AgentDisplayOverride = { ...(overrides[agentId] ?? {}) };
        for (const key of ['name', 'role', 'emoji', 'color'] as const) {
            const v = override[key];
            if (v === undefined) continue;
            if (typeof v === 'string' && v.trim()) {
                if (key === 'color') {
                    if (!/^#?[0-9a-fA-F]{3,8}$/.test(v.trim())) continue;
                    const c = v.trim();
                    existing[key] = c.startsWith('#') ? c : '#' + c;
                } else {
                    existing[key] = v.trim();
                }
            } else {
                delete existing[key];
            }
        }
        if (existing.name || existing.role || existing.emoji || existing.color) {
            overrides[agentId] = existing;
        } else {
            delete overrides[agentId];
        }
    }
    const next: CompanyState = { ...cur, displayOverrides: overrides };
    await writeCompanyState(context, next);
    return { ok: true, state: next };
}

/**
 * Set / clear a per-agent 직군 override. Pass `null` to revert the agent
 * to its def's own roleCategory. The CEO can't be reclassified (always 'ceo').
 */
export async function setAgentRoleCategory(
    context: vscode.ExtensionContext,
    agentId: string,
    category: AgentRoleCategory | null,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
    if (agentId === 'ceo') return { ok: false, reason: 'CEO 직군은 변경할 수 없습니다.' };
    const cur = readCompanyState(context);
    if (!getCompanyAgent(agentId) && !cur.customAgents?.[agentId]) {
        return { ok: false, reason: `'${agentId}' 에이전트를 찾을 수 없습니다.` };
    }
    const overrides = { ...(cur.roleCategoryOverrides ?? {}) };
    if (category === null || category === undefined) {
        delete overrides[agentId];
    } else {
        if (!VALID_ROLE_CATEGORIES.has(category) || category === 'ceo') {
            return { ok: false, reason: `'${category}'는 유효한 직군이 아닙니다.` };
        }
        overrides[agentId] = category;
    }
    const next: CompanyState = { ...cur, roleCategoryOverrides: overrides };
    await writeCompanyState(context, next);
    return { ok: true, state: next };
}

// ── Pipeline CRUD ──────────────────────────────────────────────────────────

/**
 * Save a pipeline (insert or replace). Returns the new state on success,
 * or an error reason if the payload normalizes to nothing valid.
 */
export async function upsertPipeline(
    context: vscode.ExtensionContext,
    raw: unknown,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
    const def = _normalizePipeline(raw);
    if (!def) return { ok: false, reason: '파이프라인 정의가 유효하지 않습니다 (id 필수, 소문자 시작).' };
    const cur = readCompanyState(context);
    const pipelines = { ...(cur.pipelines ?? {}), [def.id]: def };
    const next: CompanyState = { ...cur, pipelines };
    await writeCompanyState(context, next);
    return { ok: true, state: next };
}

/**
 * Remove a pipeline. Also clears `activePipelineId` if it was pointing at
 * the deleted pipeline — without that, the dispatcher would silently fall
 * back to the legacy planner path, which is confusing.
 */
export async function deletePipeline(
    context: vscode.ExtensionContext,
    pipelineId: string,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
    const cur = readCompanyState(context);
    if (!cur.pipelines || !cur.pipelines[pipelineId]) {
        return { ok: false, reason: `'${pipelineId}' 파이프라인을 찾을 수 없습니다.` };
    }
    const { [pipelineId]: _gone, ...pipelines } = cur.pipelines;
    const activePipelineId = cur.activePipelineId === pipelineId ? null : (cur.activePipelineId ?? null);
    const next: CompanyState = { ...cur, pipelines, activePipelineId };
    await writeCompanyState(context, next);
    return { ok: true, state: next };
}

/**
 * Set the active pipeline. Pass `null` to revert to the legacy CEO-planner
 * path. Unknown ids are rejected so the dispatcher can trust `state.activePipelineId`.
 */
export async function setActivePipeline(
    context: vscode.ExtensionContext,
    pipelineId: string | null,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
    const cur = readCompanyState(context);
    if (pipelineId !== null && pipelineId !== '' && !(cur.pipelines ?? {})[pipelineId]) {
        return { ok: false, reason: `'${pipelineId}' 파이프라인을 찾을 수 없습니다.` };
    }
    const next: CompanyState = { ...cur, activePipelineId: pipelineId || null };
    await writeCompanyState(context, next);
    return { ok: true, state: next };
}

// ── Derived helpers (no I/O) ────────────────────────────────────────────────

/**
 * Resolve the currently-active pipeline definition, or `null` when none is
 * selected (legacy planner path).
 */
export function resolveActivePipeline(state: CompanyState): PipelineDef | null {
    const id = state.activePipelineId;
    if (!id) return null;
    return state.pipelines?.[id] ?? null;
}

/**
 * State-aware agent lookup with 직군 override applied. Built-ins first, then
 * custom agents; the agent's effective `roleCategory` always reflects the
 * user's override (if any). Returns `undefined` when neither table has the id.
 */
export function resolveAgent(state: CompanyState, agentId: string): CompanyAgentDef | undefined {
    const base = getCompanyAgent(agentId) ?? state.customAgents?.[agentId];
    if (!base) return undefined;
    const display = state.displayOverrides?.[agentId];
    const roleOv = state.roleCategoryOverrides?.[agentId];
    // 머지 필요 없으면 base를 그대로 반환 — 불필요한 객체 복제 방지.
    const hasDisplay = !!(display && (display.name || display.role || display.emoji || display.color));
    const hasRole = !!(roleOv && VALID_ROLE_CATEGORIES.has(roleOv) && roleOv !== base.roleCategory && agentId !== 'ceo');
    if (!hasDisplay && !hasRole) return base;
    const merged: CompanyAgentDef = { ...base };
    if (hasDisplay) {
        if (display!.name) merged.name = display!.name;
        if (display!.role) merged.role = display!.role;
        if (display!.emoji) merged.emoji = display!.emoji;
        if (display!.color) merged.color = display!.color;
    }
    if (hasRole) merged.roleCategory = roleOv!;
    return merged;
}

/**
 * Returns the effective 직군 for an agent (override > def). Falls back to
 * 'support' when the agent is unknown so caller code doesn't have to handle
 * undefined.
 */
export function resolveAgentRoleCategory(state: CompanyState, agentId: string): AgentRoleCategory {
    const def = resolveAgent(state, agentId);
    return def?.roleCategory ?? 'support';
}

/**
 * All agent definitions visible to the user this turn — built-ins followed
 * by custom agents in insertion order, with 직군 overrides applied. Used by
 * the CEO planner (to build the dispatch menu) and the manage UI (to render
 * cards).
 */
export function listAllAgents(state: CompanyState): CompanyAgentDef[] {
    const out: CompanyAgentDef[] = [];
    for (const def of Object.values(COMPANY_AGENTS)) {
        out.push(resolveAgent(state, def.id) ?? def);
    }
    if (state.customAgents) {
        for (const def of Object.values(state.customAgents)) {
            out.push(resolveAgent(state, def.id) ?? def);
        }
    }
    return out;
}

/**
 * All *active* agents grouped by 직군. The pipeline editor uses this to
 * populate its "직군 → 담당" cascading dropdown. Inactive agents are filtered
 * because dispatching to a disabled agent would be a no-op anyway.
 */
export function listActiveAgentsByCategory(state: CompanyState): Record<AgentRoleCategory, CompanyAgentDef[]> {
    const buckets: Record<AgentRoleCategory, CompanyAgentDef[]> = {
        ceo: [], planner: [], researcher: [], designer: [],
        developer: [], qa: [], inspector: [], support: [],
    };
    for (const def of listAllAgents(state)) {
        const active = def.id === 'ceo' || state.activeAgentIds.includes(def.id);
        if (!active) continue;
        buckets[def.roleCategory].push(def);
    }
    return buckets;
}

/**
 * Resolve the full set of agent ids that should be available to the CEO
 * planner on this turn. CEO is always included regardless of `activeAgentIds`.
 */
export function activeAgentIds(state: CompanyState): string[] {
    const set = new Set<string>(['ceo']);
    for (const id of state.activeAgentIds) {
        if (resolveAgent(state, id)) set.add(id);
    }
    return Array.from(set);
}

/** Returns true when an agent is currently active (CEO always returns true). */
export function isAgentActive(state: CompanyState, agentId: string): boolean {
    if (agentId === 'ceo') return true;
    return state.activeAgentIds.includes(agentId);
}

/**
 * The model to use when dispatching `agentId`. Returns the override when
 * configured, otherwise `fallbackDefault` (typically the global
 * `g1nation.defaultModel`). Empty string is treated as "no override".
 */
export function modelForAgent(
    state: CompanyState,
    agentId: string,
    fallbackDefault: string,
): string {
    const override = state.modelOverrides[agentId];
    return override && override.trim() ? override.trim() : fallbackDefault;
}

/**
 * Human-readable summary for the chip tooltip / status bar:
 *   "🏢 My Company · 5 agents · default model"
 */
export function summarizeForChip(state: CompanyState): string {
    const count = activeAgentIds(state).length;
    return `${state.companyName} · ${count} agents`;
}

/**
 * Resolve the effective Knowledge Mix weight for a specific company agent.
 * Precedence: per-agent override → global `g1nation.knowledgeMix.secondBrainWeight`
 * → default 50. Returns weight + the source label, mirroring
 * `resolveKnowledgeMix` in `src/retrieval/knowledgeMix.ts` so downstream
 * code can read both the *value* and *where it came from* (handy for UI
 * hints and the scope footer).
 */
export function resolveCompanyKnowledgeMix(
    state: CompanyState,
    agentId: string,
    globalDefault: number,
): { weight: number; source: 'agent' | 'global' | 'default' } {
    const override = state.knowledgeMixOverrides?.[agentId];
    if (typeof override === 'number' && Number.isFinite(override)) {
        return { weight: Math.max(0, Math.min(100, Math.round(override))), source: 'agent' };
    }
    if (typeof globalDefault === 'number' && Number.isFinite(globalDefault)) {
        return { weight: Math.max(0, Math.min(100, Math.round(globalDefault))), source: 'global' };
    }
    return { weight: 50, source: 'default' };
}

/**
 * Resolve the *effective* prompt fields for an agent — merge the static
 * default from `agents.ts` (or the custom agent def, for user-added agents)
 * with any user-saved override. Returns plain strings so the prompt builder
 * doesn't have to worry about which source each field came from.
 */
export function resolveAgentPrompt(state: CompanyState, agentId: string): {
    persona: string;
    specialty: string;
    tagline: string;
    /** Whether *any* field is currently overridden — useful for UI hints. */
    hasOverride: boolean;
} {
    const def = resolveAgent(state, agentId);
    const ov = state.promptOverrides?.[agentId];
    return {
        persona: (ov?.persona ?? def?.persona ?? '').toString(),
        specialty: (ov?.specialty ?? def?.specialty ?? '').toString(),
        tagline: (ov?.tagline ?? def?.tagline ?? '').toString(),
        hasOverride: !!(ov && (ov.persona || ov.specialty || ov.tagline)),
    };
}

// Re-export the static catalogue so callers only have to import from one
// module to get the full picture.
export { COMPANY_AGENTS, getCompanyAgent };
