import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { 
    _getBrainDir, 
    findBrainFiles, 
    buildApiUrl,
    getActiveBrainProfile,
    getBrainProfiles,
    logError,
    logInfo,
    resolveEngine,
    summarizeText,
    openInEditorGroup
} from './utils';
import { getConfig } from './config';
import { AgentExecutor, ChatMessage } from './agent';
import { BridgeInterface } from './bridge';
import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle';
import type { ModelLifecycleManager } from './lmstudio/lifecycleManager';
import type { IActivityTracker } from './lmstudio/activityTracker';
import { handleChatMessage } from './sidebar/chatHandlers';
import { handleBrainMessage } from './sidebar/brainHandlers';
import { handleChronicleMessage } from './sidebar/chronicleHandlers';
import { handleAgentMessage } from './sidebar/agentHandlers';
import { getOrCreateAgentEntry, resolveScopeForAgent } from './skills/agentKnowledgeMap';
import { estimateModelParamsB } from './lib/contextManager';
import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader';
import {
    buildOrRefreshArchitectureDoc,
    architectureDocPathFor,
    formatArchitectureContextForPrompt,
    resolveActiveSubprojectRoot,
    scanProject,
} from './features/projectArchitecture';
import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector';
import {
    readCompanyState,
    runCompanyTurn,
    summarizeForChip,
    CompanyTurnEvent,
    COMPANY_AGENTS,
    COMPANY_AGENT_ORDER,
    ROLE_CATEGORY_LABELS,
    ROLE_CATEGORY_ORDER,
    resolveAgent,
    PIPELINE_TEMPLATES,
} from './features/company';
import { AIService } from './core/services';

export interface SidebarLmStudioDeps {
    lifecycle: ModelLifecycleManager;
    activity: IActivityTracker;
    /** Returns the list of model identifiers currently loaded in LM Studio (cached). */
    loadedModels: () => Promise<string[]>;
}

interface LastVisibleChatSnapshot {
    history: ChatMessage[];
    brainProfileId: string;
    sessionId: string | null;
    timestamp: number;
    negativePrompt?: string;
}

interface ChatSession {
    id: string;
    title: string;
    timestamp: number;
    history: ChatMessage[];
    brainProfileId: string;
    negativePrompt?: string;
}

/**
 * Sidebar UI Provider implementing BridgeInterface for BridgeServer
 */
export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface {
    public static readonly viewType = 'g1nation-v2-view';
    static readonly activeSessionStateKey = 'g1nation.activeSessionId';
    static readonly lastVisibleChatStateKey = 'g1nation.lastVisibleChat';
    static readonly blankChatStateKey = 'g1nation.blankChatActive';
    static readonly lastAgentStateKey = 'g1nation.lastAgentPath';
    static readonly chronicleProjectsStateKey = 'g1nation.chronicleProjects';
    static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId';
    static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature';
    _view?: vscode.WebviewView;
    _panel?: vscode.WebviewPanel;
    public brainEnabled = true;
    _currentSessionBrainId: string | null = null;
    _currentNegativePrompt: string = '';
    readonly _chronicle = new ProjectChronicleManager();
    _modelDiscoveryInFlight = false;
    _modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
    static readonly MODELS_CACHE_TTL_MS = 30000;

    /**
     * AbortController for the currently-running 1인 기업 turn. Cleared when
     * the turn ends (success or fail). The webview's Stop button routes
     * through `stopGeneration`, which calls `abortCompanyTurn()` to flip this
     * — the dispatcher's `signal` then short-circuits between phases.
     */
    private _companyAbort?: AbortController;

    /**
     * Open approval gates. The dispatcher emits `phase: 'awaiting-approval'`
     * for stages with `requiresApproval`, and waits on a Promise this map
     * stores. The webview surfaces 승인 / 수정요청 / 중단 buttons; clicks
     * route through `chatHandlers.respondCompanyApproval` which calls
     * `resolveApprovalGate(stageId, decision)` here.
     *
     * Keyed by stageId — only one approval may be pending per stage at a
     * time (sequential dispatch), but multiple stages across the same turn
     * each get their own entry as they hit their gate. On turn abort we
     * resolve all outstanding entries with `{ kind: 'abort' }` so the
     * dispatcher unblocks cleanly.
     */
    private _pendingApprovals = new Map<string, (d: import('./features/company/dispatcher').ApprovalDecision) => void>();

    /** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */
    private _archWatcher?: vscode.FileSystemWatcher;
    /** Debounce timer for the architecture watcher. */
    private _archWatchDebounce?: NodeJS.Timeout;
    /** Project ID the current watcher is watching — kept so we don't double-register. */
    private _archWatchedProjectId?: string;

    constructor(
        readonly _extensionUri: vscode.Uri,
        readonly _context: vscode.ExtensionContext,
        readonly _agent: AgentExecutor,
        readonly _lmStudio?: SidebarLmStudioDeps
    ) {
        this._agent.setHistoryChangeListener((history) => {
            void this._persistLastVisibleChat(history);
        });
    }

    /** Surface LM Studio lifecycle errors (load/unload failures) to the chat UI as a non-fatal toast. */
    public postLmStudioError(message: string): void {
        this._view?.webview.postMessage({ type: 'lmStudioError', value: message });
    }

    public resolveWebviewView(
        webviewView: vscode.WebviewView,
        context: vscode.WebviewViewResolveContext,
        _token: vscode.CancellationToken,
    ) {
        this._initView(webviewView);
    }

    /**
     * Open the chat as a standalone editor panel (Column 3 by default).
     * Reuses the same view-init logic via a WebviewPanel→WebviewView adapter
     * so the rest of the provider keeps using `this._view` unchanged.
     */
    public openAsPanel(column: vscode.ViewColumn = vscode.ViewColumn.Three): vscode.WebviewPanel {
        if (this._panel) {
            this._panel.reveal(column);
            return this._panel;
        }
        const panel = vscode.window.createWebviewPanel(
            SidebarChatProvider.viewType,
            'Astra Chat',
            column,
            { enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true }
        );
        this._panel = panel;
        const adapter = wrapPanelAsView(panel);
        panel.onDidDispose(() => {
            if (this._panel === panel) this._panel = undefined;
            if (this._view === adapter) this._view = undefined;
        });
        this._initView(adapter);
        return panel;
    }

    private _initView(webviewView: vscode.WebviewView) {
        this._view = webviewView;

        webviewView.webview.options = {
            enableScripts: true,
            localResourceRoots: [this._extensionUri]
        };

        // [State Persistence Fix] 사이드바가 다시 보여질 때 세팅값 자동 복원
        let _lastVisibilityRefresh = 0;
        webviewView.onDidChangeVisibility(() => {
            if (!webviewView.visible) return;
            const now = Date.now();
            // 5초 이내에 이미 갱신했으면 건너뜀
            if (now - _lastVisibilityRefresh < 5000) return;
            _lastVisibilityRefresh = now;

            logInfo('Astra view became visible, restoring state...');
            void this._sendModels();
            void this._sendBrainProfiles();
            void this._sendAgentsList();
            void this._sendReadyStatus();
        });

        webviewView.webview.html = this._getHtml(webviewView.webview);
        this._agent.setWebview(webviewView.webview);

        void this._restoreActiveSessionIntoView();
        void this._sendReadyStatus();

        webviewView.webview.onDidReceiveMessage(async (data) => {
            if (await handleChatMessage(this, data)) return;
            if (await handleBrainMessage(this, data)) return;
            if (await handleChronicleMessage(this, data)) return;
            if (await handleAgentMessage(this, data)) return;
            logInfo(`Unhandled sidebar message: ${data?.type}`);
        });
    }

    _currentSessionId: string | null = null;

    async _restoreActiveSessionIntoView() {
        if (!this._view) return;

        const blankChatActive = this._context.globalState.get<boolean>(SidebarChatProvider.blankChatStateKey, false);
        const currentHistory = this._agent.getHistory();
        
        if (blankChatActive && currentHistory.length === 0) {
            return;
        }

        const activeSessionId = this._currentSessionId || this._context.globalState.get<string | null>(SidebarChatProvider.activeSessionStateKey, null);
        if (activeSessionId) {
            const loaded = await this._loadSession(activeSessionId, true);
            if (loaded) return;

            await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
        }

        if (currentHistory.length > 0) {
            this._view.webview.postMessage({ type: 'restoreHistory', value: currentHistory });
            await this._persistLastVisibleChat(currentHistory);
            return;
        }

        const snapshot = this._context.globalState.get<LastVisibleChatSnapshot | null>(SidebarChatProvider.lastVisibleChatStateKey, null);
        if (snapshot?.history?.length) {
            this._currentSessionId = snapshot.sessionId || null;
            this._currentSessionBrainId = snapshot.brainProfileId || getActiveBrainProfile().id;
            this._currentNegativePrompt = snapshot.negativePrompt || '';
            await this._setActiveBrainProfile(this._currentSessionBrainId, true);
            this._agent.setHistory(snapshot.history);
            this._view.webview.postMessage({ 
                type: 'restoreHistory', 
                value: snapshot.history,
                negativePrompt: this._currentNegativePrompt
            });
        }
    }

    async _persistLastVisibleChat(history: ChatMessage[] = this._agent.getHistory()) {
        if (history.length === 0) {
            await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
            return;
        }

        const snapshot: LastVisibleChatSnapshot = {
            history,
            brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id,
            sessionId: this._currentSessionId,
            timestamp: Date.now(),
            negativePrompt: this._currentNegativePrompt
        };
        await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, snapshot);
    }

    async _saveCurrentSession() {
        const history = this._agent.getHistory();
        if (history.length === 0) return;

        let sessions = this._getSessions();
        const firstMsg = history.find(m => m.role === 'user')?.content;
        const title = typeof firstMsg === 'string' ? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '') : 'New Chat';
        const brainProfileId = this._currentSessionBrainId || getActiveBrainProfile().id;
        
        if (!this._currentSessionId) {
            this._currentSessionId = Date.now().toString();
            sessions.unshift({
                id: this._currentSessionId,
                title,
                timestamp: Date.now(),
                history,
                brainProfileId,
                negativePrompt: this._currentNegativePrompt
            });
        } else {
            const idx = sessions.findIndex(s => s.id === this._currentSessionId);
            if (idx >= 0) {
                sessions[idx].history = history;
                sessions[idx].timestamp = Date.now();
                sessions[idx].brainProfileId = brainProfileId;
                sessions[idx].negativePrompt = this._currentNegativePrompt;
                if (!sessions[idx].title || sessions[idx].title === 'New Chat') {
                    sessions[idx].title = title;
                }
            } else {
                sessions.unshift({
                    id: this._currentSessionId,
                    title,
                    timestamp: Date.now(),
                    history,
                    brainProfileId,
                    negativePrompt: this._currentNegativePrompt
                });
            }
        }

        // Keep only last 50 sessions
        if (sessions.length > 50) sessions = sessions.slice(0, 50);
        
        await this._putSessions(sessions);
        await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, this._currentSessionId);
        await this._persistLastVisibleChat(history);
        await this._sendSessionList();
    }

    async _sendSessionList() {
        if (!this._view) return;
        const sessions = this._getSessions();
        const list = sessions.map(s => ({
            id: s.id,
            title: s.title,
            timestamp: s.timestamp,
            brainProfileId: s.brainProfileId || '',
            messageCount: s.history.length,
            history: s.history
        }));
        this._view.webview.postMessage({ type: 'sessionList', value: list });
    }

    async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise<boolean> {
        if (!id) {
            logError('Session load requested without an id.');
            this._view?.webview.postMessage({ type: 'error', value: 'Chat session id is missing.' });
            return false;
        }

        const sessions = this._getSessions();
        const session = sessions.find(s => s.id === id) || this._getSessionById(id);
        if (session) {
            const history = Array.isArray(session.history) ? session.history : [];
            if (history.length === 0) {
                logError('Session load failed because history is empty or invalid.', { id });
                this._view?.webview.postMessage({ type: 'error', value: 'This chat session has no saved messages.' });
                return false;
            }

            this._agent.stop();
            this._currentSessionId = id;
            this._currentNegativePrompt = session.negativePrompt || '';
            const sessionBrainId = session.brainProfileId || getActiveBrainProfile().id;
            await this._setActiveBrainProfile(sessionBrainId, true);
            this._agent.setHistory(history);
            await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, id);
            await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
            await this._persistLastVisibleChat(history);
            this._view?.webview.postMessage({
                type: 'sessionLoaded',
                value: {
                    id,
                    title: session.title || 'Chat Session',
                    history,
                    negativePrompt: this._currentNegativePrompt
                }
            });
            if (!skipSessionListRefresh) {
                await this._sendSessionList();
            }
            logInfo('Chat session loaded.', { id, messages: history.length });
            return true;
        }

        logError('Session load failed because id was not found.', { id, sessionCount: sessions.length });
        this._view?.webview.postMessage({ type: 'error', value: 'Chat session was not found.' });
        return false;
    }

    async _deleteSession(id: string) {
        let sessions = this._getSessions();
        sessions = sessions.filter(s => s.id !== id);
        await this._putSessions(sessions);
        if (this._currentSessionId === id) {
            this._currentSessionId = null;
            this._agent.resetConversation();
            await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
            await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
            await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
            this.clearChat();
        }
        await this._sendSessionList();
    }

    _getSessions(): ChatSession[] {
        const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
        return rawSessions
            .map((session, index): ChatSession | null => {
                const history = Array.isArray(session?.history)
                    ? session.history.filter((message: any) =>
                        message
                        && (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
                        && message.content !== undefined
                    )
                    : [];

                if (!session?.id || history.length === 0) return null;

                const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content;
                const fallbackTitle = typeof firstMsg === 'string'
                    ? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '')
                    : `Chat ${index + 1}`;

                return {
                    id: String(session.id),
                    title: String(session.title || fallbackTitle),
                    timestamp: typeof session.timestamp === 'number' ? session.timestamp : Date.now(),
                    history,
                    brainProfileId: String(session.brainProfileId || getActiveBrainProfile().id),
                    negativePrompt: String(session.negativePrompt || '')
                };
            })
            .filter((session): session is ChatSession => !!session)
            .sort((a, b) => b.timestamp - a.timestamp)
            .slice(0, 50);
    }

    _getSessionById(id: string): ChatSession | null {
        const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
        const raw = rawSessions.find((session: any) => String(session?.id) === String(id));
        if (!raw) return null;

        const history = Array.isArray(raw.history)
            ? raw.history.filter((message: any) =>
                message
                && (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
                && message.content !== undefined
            )
            : [];
        if (history.length === 0) return null;

        const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content;
        const fallbackTitle = typeof firstMsg === 'string'
            ? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '')
            : 'Chat Session';

        return {
            id: String(raw.id),
            title: String(raw.title || fallbackTitle),
            timestamp: typeof raw.timestamp === 'number' ? raw.timestamp : Date.now(),
            history,
            brainProfileId: String(raw.brainProfileId || getActiveBrainProfile().id),
            negativePrompt: String(raw.negativePrompt || '')
        };
    }

    async _putSessions(sessions: ChatSession[]) {
        await this._context.globalState.update('chat_sessions', sessions.slice(0, 50));
    }

    async _sendBrainStatus() {
        if (!this._view) return;
        const activeBrain = getActiveBrainProfile();
        const brainDir = activeBrain.localBrainPath;
        const files = findBrainFiles(brainDir);
        this._view.webview.postMessage({ 
            type: 'brainStatus', 
            value: { 
                count: files.length, 
                path: brainDir,
                name: activeBrain.name,
                description: activeBrain.description || '',
                repo: activeBrain.secondBrainRepo || ''
            } 
        });
    }

    /**
     * One-line "current readiness" snapshot for the sidebar's status bar:
     * engine online?, model loaded?, Brain file count, active Agent + mapped knowledge
     * folder count, memory on/off, context window. Cheap — no network calls except the
     * already-cached LM Studio loaded-models list and online flag.
     */
    async _sendReadyStatus() {
        if (!this._view) return;
        let payload: any;
        try {
            const config = getConfig();
            const engineKind = resolveEngine(config.ollamaUrl);
            const activeBrain = getActiveBrainProfile();
            let brainFiles = 0;
            try { brainFiles = findBrainFiles(activeBrain.localBrainPath).length; } catch { /* ignore */ }

            const agentPath = this._context.globalState.get<string>(SidebarChatProvider.lastAgentStateKey, 'none');
            let agentName: string | null = null;
            let scopeFolders = 0;
            let mapped = false;
            if (agentPath && agentPath !== 'none') {
                agentName = path.basename(agentPath).replace(/\.md$/i, '');
                try {
                    const scope = resolveScopeForAgent(agentPath, activeBrain.localBrainPath || '');
                    scopeFolders = scope.folders.length;
                    if (scope.agent?.name) agentName = scope.agent.name;
                    mapped = scope.source !== 'none';
                } catch { /* ignore */ }
            }

            let modelLoaded: boolean | null = null;
            if (engineKind === 'lmstudio') {
                try {
                    const loaded = (await this._lmStudio?.loadedModels()) || [];
                    modelLoaded = loaded.includes(config.defaultModel);
                } catch { modelLoaded = null; }
            }

            const paramB = estimateModelParamsB(config.defaultModel);
            const cappedForSmallModel = config.smallModelContextCap > 0
                && paramB !== null && paramB <= 4
                && config.contextLength > config.smallModelContextCap;
            const effectiveContextLength = cappedForSmallModel ? config.smallModelContextCap : config.contextLength;
            payload = {
                engine: {
                    kind: engineKind,
                    label: engineKind === 'lmstudio' ? 'LM Studio' : 'Ollama',
                    online: this._modelsCache?.online ?? null,
                },
                model: { name: config.defaultModel, loaded: modelLoaded, paramB },
                brain: { name: activeBrain.name, files: brainFiles },
                agent: { name: agentName, scopeFolders, mapped },
                memory: config.memoryEnabled,
                multiAgent: config.multiAgentEnabled,
                contextLength: effectiveContextLength,
                nominalContextLength: config.contextLength,
                cappedForSmallModel,
            };
        } catch (err: any) {
            logError('Failed to build ready status.', { error: err?.message || String(err) });
            return;
        }
        this._view.webview.postMessage({ type: 'readyStatus', value: payload });
    }

    async _sendBrainProfiles() {
        if (!this._view) return;
        const activeBrain = getActiveBrainProfile();
        this._currentSessionBrainId = this._currentSessionBrainId || activeBrain.id;
        const profiles = getBrainProfiles().map((profile) => ({
            id: profile.id,
            name: profile.name,
            path: profile.localBrainPath,
            description: profile.description || '',
            repo: profile.secondBrainRepo || ''
        }));
        this._view.webview.postMessage({
            type: 'brainProfiles',
            value: {
                activeBrainId: activeBrain.id,
                profiles
            }
        });
        void this._sendReadyStatus();
    }

    _postBrainProfiles(profiles: any[], activeBrainId: string) {
        if (!this._view) return;
        this._view.webview.postMessage({
            type: 'brainProfiles',
            value: {
                activeBrainId,
                profiles: profiles.map((p: any) => ({
                    id: p.id || '',
                    name: p.name || '',
                    path: p.localBrainPath || '',
                    description: p.description || '',
                    repo: p.secondBrainRepo || ''
                }))
            }
        });
    }

    async _setActiveBrainProfile(profileId: string, silent: boolean = false) {
        const profiles = getBrainProfiles();
        const nextProfile = profiles.find((profile) => profile.id === profileId) || profiles[0];
        if (!nextProfile) return;

        await vscode.workspace.getConfiguration('g1nation').update('activeBrainId', nextProfile.id, vscode.ConfigurationTarget.Global);
        this._currentSessionBrainId = nextProfile.id;
        await this._sendBrainProfiles();
        await this._sendBrainStatus();

        if (!silent) {
            this.injectSystemMessage(`**[Brain Switched]** ${nextProfile.name}\n\`${nextProfile.localBrainPath}\``);
        }
    }

    async _manageBrains() {
        const activeBrain = getActiveBrainProfile();
        const choice = await vscode.window.showQuickPick([
            {
                label: 'Add Brain Folder',
                description: 'Create a new brain profile from a local folder'
            },
            {
                label: 'Open Active Brain Folder',
                description: activeBrain.localBrainPath
            },
            {
                label: 'Open Brain Settings',
                description: 'Edit names, paths, repos, and descriptions'
            }
        ], {
            placeHolder: `Active Brain: ${activeBrain.name} (${activeBrain.localBrainPath})`
        });

        if (!choice) return;

        if (choice.label === 'Add Brain Folder') {
            await this._addBrainProfile();
            return;
        }

        if (choice.label === 'Open Active Brain Folder') {
            if (!fs.existsSync(activeBrain.localBrainPath)) {
                fs.mkdirSync(activeBrain.localBrainPath, { recursive: true });
            }
            await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(activeBrain.localBrainPath));
            return;
        }

        vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation.brainProfiles');
    }

    async _addBrainProfile() {
        const selected = await vscode.window.showOpenDialog({
            canSelectFiles: false,
            canSelectFolders: true,
            canSelectMany: false,
            openLabel: 'Use as Brain'
        });

        const folder = selected?.[0]?.fsPath;
        if (!folder) return;

        const defaultName = path.basename(folder) || 'New Brain';
        const name = await vscode.window.showInputBox({
            prompt: 'Name this brain profile',
            value: defaultName,
            validateInput: (value) => value.trim() ? null : 'Brain name is required.'
        });
        if (!name) return;

        const description = await vscode.window.showInputBox({
            prompt: 'Optional description shown in the Astra sidebar',
            value: ''
        });

        const repo = await vscode.window.showInputBox({
            prompt: 'Optional Second Brain Git repository URL',
            value: ''
        });

        // Read raw settings directly to avoid virtual default-brain (injected in memory by getConfig())
        // being saved into the settings file and corrupting the profile list on next load.
        const cfg = vscode.workspace.getConfiguration('g1nation');
        const existingRaw: any[] = cfg.get<any[]>('brainProfiles', []) || [];

        const idBase = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'brain';
        let id = idBase;
        let suffix = 2;
        while (existingRaw.some((p: any) => p.id === id)) {
            id = `${idBase}-${suffix++}`;
        }

        const newProfile = {
            id,
            name: name.trim(),
            localBrainPath: folder,
            secondBrainRepo: (repo || '').trim(),
            description: (description || '').trim()
        };
        const nextProfiles = [...existingRaw, newProfile];

        await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
        await cfg.update('activeBrainId', id, vscode.ConfigurationTarget.Global);
        this._currentSessionBrainId = id;

        // cfg.update() is async and VSCode's config cache may not reflect the new value
        // immediately, so we post the freshly-built profile list directly.
        this._postBrainProfiles(nextProfiles, id);
        await this._sendBrainStatus();
        this.injectSystemMessage(`**[Brain Added]** ${name.trim()}\n\`${folder}\``);
    }

    async _editBrainProfile(profileId?: string) {
        const currentProfiles = getBrainProfiles();
        const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
        if (!target) return;

        const name = await vscode.window.showInputBox({
            prompt: 'Edit brain profile name',
            value: target.name,
            validateInput: (value) => value.trim() ? null : 'Brain name is required.'
        });
        if (!name) return;

        const folder = await vscode.window.showInputBox({
            prompt: 'Edit local brain folder path',
            value: target.localBrainPath,
            validateInput: (value) => value.trim() ? null : 'Brain folder path is required.'
        });
        if (!folder) return;

        const description = await vscode.window.showInputBox({
            prompt: 'Edit optional description shown in the Astra sidebar',
            value: target.description || ''
        });

        const repo = await vscode.window.showInputBox({
            prompt: 'Edit optional Second Brain Git repository URL',
            value: target.secondBrainRepo || ''
        });

        const nextProfiles = currentProfiles.map((profile) => profile.id === target.id
            ? {
                ...profile,
                name: name.trim(),
                localBrainPath: folder.trim(),
                secondBrainRepo: (repo || '').trim(),
                description: (description || '').trim()
            }
            : profile
        );

        const cfg = vscode.workspace.getConfiguration('g1nation');
        await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
        await cfg.update('activeBrainId', target.id, vscode.ConfigurationTarget.Global);
        this._currentSessionBrainId = target.id;
        this._postBrainProfiles(nextProfiles, target.id);
        await this._sendBrainStatus();
        this.injectSystemMessage(`**[Brain Updated]** ${name.trim()}\n\`${folder.trim()}\``);
    }

    async _deleteBrainProfile(profileId?: string) {
        const currentProfiles = getBrainProfiles();
        const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
        if (!target) return;

        if (currentProfiles.length <= 1) {
            vscode.window.showWarningMessage('At least one brain profile is required.');
            return;
        }

        const confirm = await vscode.window.showWarningMessage(
            `Delete brain profile "${target.name}"? The folder itself will not be deleted.`,
            { modal: true },
            'Delete Profile'
        );
        if (confirm !== 'Delete Profile') return;

        const nextProfiles = currentProfiles.filter((profile) => profile.id !== target.id);
        const nextActive = nextProfiles[0];
        const cfg = vscode.workspace.getConfiguration('g1nation');
        await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
        await cfg.update('activeBrainId', nextActive.id, vscode.ConfigurationTarget.Global);
        this._currentSessionBrainId = nextActive.id;
        this._postBrainProfiles(nextProfiles, nextActive.id);
        await this._sendBrainStatus();
        this.injectSystemMessage(`**[Brain Deleted]** ${target.name}`);
    }

    async _saveWikiRaw() {
        const history = this._agent.getHistory();
        if (history.length === 0) {
            vscode.window.showWarningMessage('There is no conversation to save as wiki raw data.');
            return;
        }

        const activeBrain = getActiveBrainProfile();
        const rawDir = path.join(activeBrain.localBrainPath, 'raw-data');
        if (!fs.existsSync(rawDir)) {
            fs.mkdirSync(rawDir, { recursive: true });
        }

        const category = await vscode.window.showInputBox({
            prompt: 'Wiki raw data category',
            value: 'Project Notes'
        });
        if (category === undefined) return;

        const expectedValue = await vscode.window.showInputBox({
            prompt: 'Expected value or future use',
            value: 'Reusable as source material for a future wiki document.'
        });
        if (expectedValue === undefined) return;

        const timestamp = new Date();
        const slug = this._slugify(history.find((message) => message.role === 'user')?.content || 'conversation');
        const fileName = `${this._formatTimestampForFile(timestamp)}-${slug}.md`;
        const filePath = path.join(rawDir, fileName);
        const markdown = this._buildWikiRawMarkdown(history, {
            category: category.trim() || 'Project Notes',
            expectedValue: expectedValue.trim() || 'Reusable as source material for a future wiki document.',
            activeBrainName: activeBrain.name,
            activeBrainPath: activeBrain.localBrainPath,
            createdAt: timestamp.toISOString()
        });

        await vscode.workspace.fs.writeFile(vscode.Uri.file(filePath), Buffer.from(markdown, 'utf8'));
        vscode.window.showInformationMessage(`Wiki raw data saved: ${path.basename(filePath)}`);
        this.injectSystemMessage(`**[Wiki Raw Saved]** \`${filePath}\``);
    }

    _buildWikiRawMarkdown(history: ChatMessage[], meta: {
        category: string;
        expectedValue: string;
        activeBrainName: string;
        activeBrainPath: string;
        createdAt: string;
    }): string {
        const firstUserMessage = history.find((message) => message.role === 'user')?.content || '';
        const latestUserMessage = [...history].reverse().find((message) => message.role === 'user')?.content || '';
        const latestAssistantMessage = [...history].reverse().find((message) => message.role === 'assistant')?.content || '';
        const rationaleNotes = history
            .filter((message) => message.role === 'assistant' && message.rationale)
            .map((message, index) => [
                `### Process Note ${index + 1}`,
                message.rationale?.problem ? `- Problem: ${message.rationale.problem}` : '',
                message.rationale?.goal ? `- Goal: ${message.rationale.goal}` : '',
                message.rationale?.reasoning ? `- Reasoning: ${message.rationale.reasoning}` : ''
            ].filter(Boolean).join('\n'))
            .join('\n\n');

        const transcript = history
            .map((message, index) => [
                `### ${index + 1}. ${message.role.toUpperCase()}`,
                '',
                message.content.trim() || '(empty)'
            ].join('\n'))
            .join('\n\n');

        return [
            '---',
            `title: "${this._escapeYamlString(this._summarizeForTitle(firstUserMessage))}"`,
            `category: "${this._escapeYamlString(meta.category)}"`,
            `created_at: "${meta.createdAt}"`,
            `source: "Astra conversation"`,
            `brain: "${this._escapeYamlString(meta.activeBrainName)}"`,
            'status: raw',
            '---',
            '',
            `# ${this._summarizeForTitle(firstUserMessage)}`,
            '',
            '## Category',
            meta.category,
            '',
            '## What This Contains',
            this._summarizeTextForWiki(latestAssistantMessage || firstUserMessage),
            '',
            '## Expected Value',
            meta.expectedValue,
            '',
            '## Discovery Process',
            rationaleNotes || 'No explicit rationale metadata was captured. Use the transcript below to reconstruct the reasoning path.',
            '',
            '## Conclusion',
            [
                'This conclusion was derived from the latest user request and assistant response.',
                '',
                `- Latest user request: ${this._summarizeTextForWiki(latestUserMessage)}`,
                `- Latest assistant conclusion: ${this._summarizeTextForWiki(latestAssistantMessage)}`
            ].join('\n'),
            '',
            '## Coding Implementation Notes',
            'Use this section to turn the raw transcript into a wiki-ready implementation note. Capture which files changed, why they changed, and what verification was run.',
            '',
            '## Source Brain',
            `- Name: ${meta.activeBrainName}`,
            `- Path: ${meta.activeBrainPath}`,
            '',
            '## Raw Conversation Transcript',
            transcript,
            ''
        ].join('\n');
    }

    _formatTimestampForFile(date: Date): string {
        return date.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '');
    }

    _slugify(value: string): string {
        const slug = value
            .toLowerCase()
            .replace(/[^a-z0-9가-힣]+/g, '-')
            .replace(/^-|-$/g, '')
            .slice(0, 48);
        return slug || 'conversation';
    }

    _summarizeForTitle(value: string): string {
        const normalized = value.replace(/\s+/g, ' ').trim();
        if (!normalized) return 'Astra Conversation Raw Data';
        return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized;
    }

    _summarizeTextForWiki(value: string): string {
        const normalized = value.replace(/\s+/g, ' ').trim();
        if (!normalized) return 'Not captured.';
        return normalized.length > 500 ? `${normalized.slice(0, 500)}...` : normalized;
    }

    _escapeYamlString(value: string): string {
        return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
    }

    // --- BridgeInterface Methods ---

    public injectSystemMessage(msg: string): void {
        this._view?.webview.postMessage({ type: 'streamStart' });
        this._view?.webview.postMessage({ type: 'streamChunk', value: msg });
        this._view?.webview.postMessage({ type: 'streamEnd' });
    }

    public getHistoryText(): string {
        return "Conversation history placeholder for evaluation.";
    }

    public sendPromptFromExtension(prompt: string): void {
        if (this._view) {
            this._view.show?.(true);
            this._view.webview.postMessage({ type: 'injectPrompt', value: prompt });
        }
    }

    public findBrainFiles(dir: string): string[] {
        return findBrainFiles(dir);
    }

    // --- End BridgeInterface ---

    public focusInput() {
        this._view?.webview.postMessage({ type: 'focusInput' });
    }

    public clearChat() {
        this._view?.webview.postMessage({ type: 'clearChat' });
    }

    public async syncBrain() {
        const activeBrain = getActiveBrainProfile();
        const brainDir = activeBrain.localBrainPath;
        if (!fs.existsSync(brainDir)) {
            vscode.window.showErrorMessage("Second Brain directory not found.");
            return;
        }
        vscode.window.withProgress({
            location: vscode.ProgressLocation.Notification,
            title: "Astra: Syncing Second Brain...",
            cancellable: false
        }, async () => {
            try {
                const { execSync } = require('child_process');
                execSync(`git add .`, { cwd: brainDir });
                execSync(`git commit -m "[G1-Sync] Manual knowledge update"`, { cwd: brainDir });
                execSync(`git push`, { cwd: brainDir });
                vscode.window.showInformationMessage("Second Brain synced successfully.");
            } catch (err: any) {
                vscode.window.showInformationMessage("Brain Synced: Local knowledge is up-to-date (no changes to push).");
            }
        });
    }

    _getChronicleProjects(): ProjectProfile[] {
        const raw = this._context.globalState.get<ProjectProfile[]>(SidebarChatProvider.chronicleProjectsStateKey, []) || [];
        const valid = raw.filter((profile: ProjectProfile) =>
            profile
            && typeof profile.projectId === 'string'
            && typeof profile.projectName === 'string'
            && typeof profile.recordRoot === 'string'
        );

        if (valid.length > 0) return valid;

        const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
        if (!workspaceRoot) return [];

        const now = new Date().toISOString();
        const projectName = path.basename(workspaceRoot) || 'Current Project';
        return [{
            projectId: this._slugify(projectName),
            projectName,
            projectRoot: workspaceRoot,
            recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
            description: 'Auto-detected current workspace project.',
            corePurpose: 'Capture project planning, decisions, development notes, bugs, and retrospectives as Markdown.',
            targetUsers: ['Project developer'],
            avoidDirections: ['Do not tightly couple records to chat execution internals.'],
            detailLevel: 'standard',
            createdAt: now,
            updatedAt: now
        }];
    }

    async _putChronicleProjects(projects: ProjectProfile[]) {
        await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects);
    }

    /**
     * One-time repair for chronicle projects that were stored before the
     * multi-subproject fix landed.
     *
     * Old activation code always treated `workspaceFolders[0]` (= the open
     * parent folder, e.g. `/.../Antigravity`) as the project root, so every
     * subproject the user activated (`ConnectAI`, `Datacollector_MAC`,
     * `Skybound`, …) ended up with `projectRoot === <parent>` in globalState.
     * That breaks reload: `_ensureActiveProjectForWorkspace` case 2 matches
     * the corrupted entry on every boot and force-switches the active
     * project to whichever bad row it found first.
     *
     * The repair: for each chronicle project whose `projectName` does NOT
     * match the basename of its stored `projectRoot` (i.e. it can't actually
     * be the project root), and where a same-name subfolder exists under
     * the current workspace, retarget the entry to that subfolder. Idempotent
     * — re-running it after the first pass changes nothing.
     */
    async _repairCorruptedChronicleProjectRoots(): Promise<void> {
        const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
        if (!wsRoot) return;
        const projects = this._getChronicleProjects();
        if (projects.length === 0) return;
        const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase();
        const wsRootNorm = norm(wsRoot);
        let changed = false;
        const repaired = projects.map((p) => {
            if (!p.projectName || !p.projectRoot) return p;
            const rootBase = path.basename(p.projectRoot).toLowerCase();
            const nameMatches = rootBase === p.projectName.toLowerCase();
            if (nameMatches) return p; // root basename agrees with name → trust it
            // Only repair entries that look like they were silently captured at
            // the workspace parent. Anything else (different machine, custom
            // path) we leave alone.
            if (norm(p.projectRoot) !== wsRootNorm) return p;
            const candidate = path.join(wsRoot, p.projectName);
            try {
                if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) return p;
            } catch {
                return p;
            }
            const newDocPath = path.join(candidate, '.astra', 'project-context', 'architecture.md');
            changed = true;
            return {
                ...p,
                projectRoot: candidate,
                recordRoot: path.join(candidate, 'docs', 'records', p.projectName),
                architectureDocPath: fs.existsSync(newDocPath) ? newDocPath : p.architectureDocPath,
            };
        });
        if (!changed) return;
        await this._putChronicleProjects(repaired);
        const fixedCount = repaired.filter((p, i) => p !== projects[i]).length;
        logInfo('architecture: repaired chronicle projects with wrong projectRoot.', {
            wsRoot, count: fixedCount,
        });
    }

    // ─── Project Architecture Context (Feature 2) ──────────────────────────────
    //
    // Activation flow:
    //   1. Chat preprocessor (or an explicit "Activate" button) calls
    //      _tryActivateArchitectureFromText(latestUserMessage).
    //   2. If the text yields a known/inferable project, we set it active,
    //      ensure the architecture doc exists, register the file watcher,
    //      and broadcast the state to the webview as a chip.
    //   3. On every subsequent prompt, _handlePrompt reads
    //      _buildProjectArchitectureContext() and injects it into the model
    //      call. Detach → empty context + watcher disposed.

    /** True if the active project has its architecture doc auto-attached. */
    _isArchitectureAutoAttached(): boolean {
        const p = this._getActiveChronicleProject();
        return !!(p && p.architectureDocPath && p.architectureAutoAttach !== false);
    }

    /**
     * Try to resolve a project handle from arbitrary user text. Combines:
     *   • Korean / English natural-language activation phrasing.
     *   • Absolute filesystem paths.
     *   • The existing Chronicle project list as ground truth for name matches.
     */
    _detectProjectFromText(text: string): KnownProject | null {
        const known = this._getChronicleProjects().map<KnownProject>((p) => ({
            projectId: p.projectId,
            projectName: p.projectName,
            projectRoot: p.projectRoot,
        }));
        const hit = detectProjectIntent(text || '', known);
        return hit?.project ?? null;
    }

    /**
     * Activate (or refresh) architecture context for the project resolved from
     * `text`. No-op when no project is detected. Returns the activated profile
     * id, or `null` if nothing changed. Side-effects: writes the architecture
     * doc, marks the project active, broadcasts the chip state.
     */
    async _tryActivateArchitectureFromText(text: string): Promise<string | null> {
        const detected = this._detectProjectFromText(text);
        if (!detected) return null;
        return this._activateArchitectureForProject(detected.projectId, {
            fallbackName: detected.projectName,
            fallbackRoot: detected.projectRoot,
        });
    }

    /**
     * Make `projectId` the active project, ensure its architecture doc exists,
     * and register the file watcher. If the project isn't in the chronicle
     * store yet (path-only match), materialise a minimal profile so subsequent
     * turns can find it.
     */
    async _activateArchitectureForProject(
        projectId: string,
        opts: { fallbackName?: string; fallbackRoot?: string } = {}
    ): Promise<string | null> {
        const projects = this._getChronicleProjects();
        let profile = projects.find((p) => p.projectId === projectId);

        // Materialise a stub when the user references a project by path that
        // isn't yet registered. We use the path's basename as the name and the
        // standard records location as recordRoot so existing Chronicle code
        // keeps working.
        if (!profile) {
            const root = opts.fallbackRoot || '';
            if (!root) {
                logError('architecture: cannot activate without project root.', { projectId });
                return null;
            }
            const name = opts.fallbackName || path.basename(root) || projectId;
            const now = new Date().toISOString();
            profile = {
                projectId,
                projectName: name,
                projectRoot: root,
                recordRoot: path.join(root, 'docs', 'records', name),
                description: 'Auto-created by Project Architecture activation.',
                corePurpose: '',
                detailLevel: 'standard',
                createdAt: now,
                updatedAt: now,
            };
            projects.push(profile);
            await this._putChronicleProjects(projects);
        }

        if (!profile.projectRoot) {
            logError('architecture: profile has no projectRoot; cannot scan.', { projectId });
            return null;
        }

        // Generate or refresh the doc. Always idempotent — the generator
        // preserves user-owned sections.
        const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
        const now = new Date().toISOString();
        const updated: ProjectProfile = {
            ...profile,
            architectureDocPath: result.docPath,
            // [BUGFIX] Previously used `?? true`, but `??` only fallbacks on null/undefined.
            // After a user Detach, `architectureAutoAttach === false`, so `false ?? true`
            // stays `false` and the chip stays "detached — click Attach to re-enable"
            // forever no matter how many times the user clicks Attach. The activation
            // path is an explicit user intent to re-enable, so force `true` here.
            architectureAutoAttach: true,
            architectureAutoUpdate: profile.architectureAutoUpdate ?? true,
            architectureLastUpdated: now,
            architectureLastScanSignature: result.scan.signature,
            updatedAt: now,
        };
        const next = projects.map((p) => (p.projectId === updated.projectId ? updated : p));
        await this._putChronicleProjects(next);
        await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, projectId);

        // (Re)register the watcher for this project.
        this._registerArchitectureWatcher(updated);

        // Tell the webview to show / refresh the chip.
        await this._sendArchitectureStatus();
        logInfo('architecture: activated.', {
            projectId, docPath: result.docPath, created: result.created,
        });
        return projectId;
    }

    /** Detach project mode: stop auto-attaching the doc and dispose the watcher. */
    async _detachArchitecture(): Promise<void> {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            this._disposeArchitectureWatcher();
            await this._sendArchitectureStatus();
            return;
        }
        const projects = this._getChronicleProjects();
        const next = projects.map((p) => p.projectId === profile.projectId
            ? { ...p, architectureAutoAttach: false }
            : p);
        await this._putChronicleProjects(next);
        this._disposeArchitectureWatcher();
        await this._sendArchitectureStatus();
        logInfo('architecture: detached.', { projectId: profile.projectId });
    }

    /**
     * Force a refresh of the architecture doc for the active project.
     *
     * Always rewrites the auto-managed block (so the "Last Refresh" stamp +
     * stats reflect the click). Emits an `architectureRefreshResult` event
     * with the per-file work breakdown — that's what makes the operation
     * visibly trustworthy in the UI (no more "0.1s, nothing visible").
     */
    async _refreshArchitecture(): Promise<void> {
        const startedAt = Date.now();
        const profile = this._getActiveChronicleProject();
        if (!profile || !profile.projectRoot) {
            this._view?.webview.postMessage({
                type: 'architectureRefreshFailed',
                value: { reason: 'no-active-project' },
            });
            return;
        }
        const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
        const now = new Date().toISOString();
        const projects = this._getChronicleProjects();
        const next = projects.map((p) => p.projectId === profile.projectId
            ? {
                ...p,
                architectureDocPath: result.docPath,
                architectureLastUpdated: now,
                architectureLastScanSignature: result.scan.signature,
                updatedAt: now,
            }
            : p);
        await this._putChronicleProjects(next);
        await this._sendArchitectureStatus();
        // Tell the webview exactly what the scan did so the user can
        // trust the "Refresh" button actually ran. The three numbers
        // (newly / cached / deleted) together explain whether the doc
        // changed or just had its timestamp bumped.
        this._view?.webview.postMessage({
            type: 'architectureRefreshResult',
            value: {
                projectName: profile.projectName,
                docPath: result.docPath,
                newlyAnalyzed: result.refreshStats.newlyAnalyzed,
                cached: result.refreshStats.cached,
                deleted: result.refreshStats.deleted.length,
                durationMs: Date.now() - startedAt,
            },
        });
    }

    /**
     * Re-attach the architecture context for the active project after a
     * prior Detach. Rebuilds the doc (so the user gets a fresh scan),
     * flips `architectureAutoAttach=true`, re-registers the watcher, and
     * broadcasts the chip back to its active state. The complement of
     * `_detachArchitecture`.
     */
    async _attachArchitecture(): Promise<void> {
        // `_ensureActiveProjectForWorkspace` guarantees the active project
        // matches the current VS Code workspace — without that, hitting
        // Attach right after opening a different folder would silently
        // attach to whatever was last active in the *previous* workspace.
        const profile = await this._ensureActiveProjectForWorkspace();
        if (!profile || !profile.projectRoot) {
            this._view?.webview.postMessage({
                type: 'architectureRefreshFailed',
                value: { reason: 'no-active-project' },
            });
            return;
        }
        await this._activateArchitectureForProject(profile.projectId, {
            fallbackName: profile.projectName,
            fallbackRoot: profile.projectRoot,
        });
    }

    /**
     * Make sure the active chronicle project actually corresponds to the
     * folder the user has open in VS Code. Three cases:
     *
     *   1. Active project already matches workspace → return it as-is.
     *   2. A *different* chronicle project matches the workspace → flip
     *      the active id to that one (the user switched folders since
     *      last session).
     *   3. No chronicle project matches → synthesise a new one from the
     *      workspace folder name + register it.
     *
     * Returns the (possibly newly created) active project, or `null` when
     * no workspace is open. Idempotent — calling repeatedly with no change
     * is free.
     */
    async _ensureActiveProjectForWorkspace(): Promise<ProjectProfile | null> {
        const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
        if (!wsRoot) return null;
        // When the parent folder contains several subprojects, use the active
        // editor's location to pick the *effective* subproject root instead of
        // always reporting the parent.
        const hint = vscode.window.activeTextEditor?.document.uri.fsPath
            ?? vscode.window.visibleTextEditors[0]?.document.uri.fsPath;
        const resolved = resolveActiveSubprojectRoot(wsRoot, hint);
        const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase();
        const active = this._getActiveChronicleProject();
        const projects = this._getChronicleProjects();
        // Did the editor hint actually point at a nested subproject? If the
        // resolver fell back to the workspace root (no nested marker, or the
        // Astra sidebar itself was focused so no editor existed), we must NOT
        // clobber the user's current active project — they may have set it by
        // hand via the project picker, or by typing a project intent. Auto-
        // switching is only safe when there is a clear editor-driven signal.
        const isNestedHit = !!resolved && norm(resolved) !== norm(wsRoot);
        if (!isNestedHit && active) {
            return active;
        }
        const workspaceRoot = isNestedHit ? resolved : wsRoot;
        if (active && active.projectRoot && norm(active.projectRoot) === norm(workspaceRoot)) {
            return active;
        }
        // Case 2: another chronicle project matches → switch active to it
        const matching = projects.find((p) => norm(p.projectRoot) === norm(workspaceRoot));
        if (matching) {
            await this._context.globalState.update(
                SidebarChatProvider.activeChronicleProjectStateKey,
                matching.projectId,
            );
            logInfo('architecture: switched active project to match workspace.', {
                from: active?.projectId,
                to: matching.projectId,
            });
            return matching;
        }
        // Case 3: synthesise a fresh entry for this workspace
        const projectName = path.basename(workspaceRoot) || 'Current Project';
        const projectId = this._slugify(projectName);
        const now = new Date().toISOString();
        const profile: ProjectProfile = {
            projectId,
            projectName,
            projectRoot: workspaceRoot,
            recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
            description: 'Auto-detected from workspace folder.',
            corePurpose: '',
            detailLevel: 'standard',
            createdAt: now,
            updatedAt: now,
        };
        const nextProjects = projects.filter((p) => p.projectId !== projectId).concat(profile);
        await this._putChronicleProjects(nextProjects);
        await this._context.globalState.update(
            SidebarChatProvider.activeChronicleProjectStateKey,
            projectId,
        );
        logInfo('architecture: registered new project from workspace.', {
            projectId, projectRoot: workspaceRoot,
        });
        return profile;
    }

    /**
     * Build the `projectArchitectureContext` string for the active prompt.
     * Returns empty string when auto-attach is off or the doc is missing —
     * agent.ts then treats it as "no block" and emits nothing extra.
     */
    _buildProjectArchitectureContext(): string {
        const p = this._getActiveChronicleProject();
        if (!p || p.architectureAutoAttach === false || !p.architectureDocPath) return '';
        if (!fs.existsSync(p.architectureDocPath)) return '';
        return formatArchitectureContextForPrompt({
            projectName: p.projectName,
            docPath: p.architectureDocPath,
            // Pass the project root so the `Source:` header in the prompt is
            // workspace-relative — keeps the prompt portable across machines.
            projectRoot: p.projectRoot,
            lastUpdated: p.architectureLastUpdated,
        });
    }

    /**
     * Webview chip data. Three states:
     *
     *   1. **active**     — project mode is on; doc is being auto-attached.
     *   2. **inactive**   — there's a project + workspace, but architecture
     *                       is either never-activated or user-detached.
     *                       The chip shows an `[Attach]` button instead of
     *                       hiding entirely, so users always have a one-
     *                       click path back into project mode.
     *   3. **hidden**     — no workspace open and no project at all.
     *
     * Also does an auto-activation pass for the *fresh-workspace* case:
     * when the active project has no `architectureDocPath` yet AND the
     * user hasn't explicitly detached, we generate the doc + flip
     * `autoAttach=true` so the user opens a new folder and immediately
     * sees the architecture context working. Existing detach choices are
     * always respected.
     */
    async _sendArchitectureStatus(): Promise<void> {
        if (!this._view) return;
        // Always sync the active project to the current VS Code workspace
        // before reporting — otherwise switching workspaces leaves the
        // chip pointing at the *previous* project's doc.
        const p = await this._ensureActiveProjectForWorkspace();
        if (!p) {
            this._view.webview.postMessage({ type: 'architectureStatus', value: { active: false } });
            return;
        }
        const wasDetached = p.architectureAutoAttach === false;
        const hasDoc = !!(p.architectureDocPath && fs.existsSync(p.architectureDocPath));

        // Auto-activation for fresh workspaces: never been activated AND
        // never been detached → kick off a build and re-broadcast. Single
        // recursion is safe because the post-activate state will hit the
        // `active` branch below.
        if (!hasDoc && !wasDetached && p.projectRoot) {
            try {
                await this._activateArchitectureForProject(p.projectId, {
                    fallbackName: p.projectName,
                    fallbackRoot: p.projectRoot,
                });
                return; // _activateArchitectureForProject sends its own status
            } catch (e: any) {
                logError('architecture: auto-activate failed.', { error: e?.message ?? String(e) });
                // Fall through to the inactive state so the user still sees an Attach button.
            }
        }

        const fullyActive = hasDoc && !wasDetached;
        if (fullyActive) {
            this._view.webview.postMessage({
                type: 'architectureStatus',
                value: {
                    active: true,
                    projectId: p.projectId,
                    projectName: p.projectName,
                    docPath: p.architectureDocPath,
                    lastUpdated: p.architectureLastUpdated || '',
                    autoUpdate: p.architectureAutoUpdate !== false,
                },
            });
            // Re-register the watcher in case it was disposed (e.g. workspace switch).
            this._registerArchitectureWatcher(p);
        } else {
            // Inactive but attachable: surface the project name + an Attach hook.
            this._view.webview.postMessage({
                type: 'architectureStatus',
                value: {
                    active: false,
                    canAttach: !!p.projectRoot,
                    projectId: p.projectId,
                    projectName: p.projectName,
                    // Distinguishes "never activated" from "detached" so the
                    // chip can choose the right label ("Activate" vs "Re-attach").
                    detached: wasDetached,
                },
            });
        }
    }

    // ─── 1인 기업 (Company) Mode ────────────────────────────────────────────
    //
    // When `companyState.enabled` is true, prompts coming through the chat
    // handler are routed to `_runCompanyTurn` instead of the normal
    // AgentExecutor path. The dispatcher emits `companyTurnUpdate` events as
    // each phase progresses; the webview shows a step-by-step header for
    // CEO planning, each specialist's dispatch, and the final synthesis.

    /** True iff company mode is active. Cheap — read from globalState. */
    isCompanyModeEnabled(): boolean {
        return readCompanyState(this._context).enabled;
    }

    /**
     * Abort the currently-running 1인 기업 turn if any. Returns true when an
     * abort was actually fired (so the chat handler can skip `agent.stop()`
     * — the company path never touches AgentExecutor). The dispatcher will
     * see `signal.aborted` at its next phase boundary and emit
     * `phase: 'aborted'`; `_runCompanyTurn`'s finally clause then posts
     * `streamEnd` so the UI unlocks.
     */
    abortCompanyTurn(): boolean {
        if (!this._companyAbort) return false;
        this._companyAbort.abort();
        // 승인 게이트 대기 중인 모든 stage를 'abort'로 해제. 안 하면 dispatcher가
        // 영원히 await 상태로 남아 turn이 절대 종료 안 됨.
        for (const resolve of this._pendingApprovals.values()) {
            try { resolve({ kind: 'abort' }); } catch { /* noop */ }
        }
        this._pendingApprovals.clear();
        return true;
    }

    /**
     * Called by chatHandlers when the user clicks an approval card button.
     * Resolves the dispatcher's awaitApproval promise for `stageId`. Idempotent
     * — extra clicks after the first one are silently dropped.
     */
    resolveApprovalGate(stageId: string, decision: import('./features/company/dispatcher').ApprovalDecision): boolean {
        const resolve = this._pendingApprovals.get(stageId);
        if (!resolve) return false;
        this._pendingApprovals.delete(stageId);
        try { resolve(decision); } catch { /* noop */ }
        return true;
    }

    /**
     * Push the full pipeline catalogue + active id to the webview so the
     * editor overlay can render the cards. Pipelines are user-defined
     * (no built-ins) so an empty list is the default for new users.
     */
    async _sendCompanyPipelines(): Promise<void> {
        if (!this._view) return;
        const state = readCompanyState(this._context);
        // 직군별 활성 에이전트도 같이 — 파이프라인 에디터가 "직군 → 담당자"
        // cascading dropdown을 채울 때 이 페이로드만 보고 그릴 수 있게.
        const { listActiveAgentsByCategory } = await import('./features/company');
        const byCategory = listActiveAgentsByCategory(state);
        // CompanyAgentDef를 통째로 보내는 대신 UI에 필요한 필드만 추려서.
        const slimByCategory: Record<string, Array<{ id: string; name: string; emoji: string }>> = {};
        for (const [cat, defs] of Object.entries(byCategory)) {
            slimByCategory[cat] = defs.map((d) => ({ id: d.id, name: d.name, emoji: d.emoji }));
        }
        // 템플릿 카탈로그 — 가벼운 메타데이터만 (stages는 stamp 시점에 한 번
        // 더 요청). UI는 dropdown 옵션 텍스트만 필요.
        const templates = PIPELINE_TEMPLATES.map((t) => ({
            templateId: t.templateId,
            name: t.name,
            description: t.description,
            stageCount: t.stages.length,
            suggestedPipelineId: t.suggestedPipelineId,
            suggestedPipelineName: t.suggestedPipelineName,
        }));
        this._view.webview.postMessage({
            type: 'companyPipelines',
            value: {
                pipelines: state.pipelines ?? {},
                activePipelineId: state.activePipelineId ?? null,
                roleCategoryLabels: ROLE_CATEGORY_LABELS,
                roleCategoryOrder: ROLE_CATEGORY_ORDER,
                activeAgentsByCategory: slimByCategory,
                templates,
            },
        });
    }

    /** Send the chip state (active flag + agent count + name) to the webview. */
    async _sendCompanyStatus(): Promise<void> {
        if (!this._view) return;
        const state = readCompanyState(this._context);
        this._view.webview.postMessage({
            type: 'companyStatus',
            value: {
                enabled: state.enabled,
                companyName: state.companyName,
                summary: summarizeForChip(state),
                activeAgentIds: state.activeAgentIds,
                modelOverrides: state.modelOverrides,
            },
        });
    }

    /**
     * Push the full agent catalogue when the manage panel opens. Each entry
     * carries both the *default* (from `agents.ts`) and *override* (from
     * globalState) fields so the UI can show the user what they've edited,
     * gray out unchanged fields, and offer a Reset button per agent.
     */
    async _sendCompanyAgents(): Promise<void> {
        if (!this._view) return;
        const state = readCompanyState(this._context);
        const cfg = getConfig();
        const globalWeight = cfg.knowledgeMixSecondBrainWeight ?? 50;
        // Built-ins first (insertion order from agents.ts), then user-added
        // customs in their own order. `custom: true` lets the UI render a
        // delete button only for user-added entries.
        const builtinIds = COMPANY_AGENT_ORDER.filter((id) => !!COMPANY_AGENTS[id]);
        const customIds = state.customAgents ? Object.keys(state.customAgents) : [];
        const orderedIds = [...builtinIds, ...customIds];
        const renderEntry = (id: string) => {
            const builtin = COMPANY_AGENTS[id];
            const custom = state.customAgents?.[id];
            const baseDef = builtin ?? custom;
            if (!baseDef) return null;
            // 직군 override 적용된 effective def. 카드의 드롭다운이 옳은 선택값을
            // 보이려면 override 결과를 보내야 한다.
            const effective = resolveAgent(state, id) ?? baseDef;
            const isCustom = !builtin;
            const override = state.promptOverrides[id] || {};
            const kmOverride = state.knowledgeMixOverrides[id];
            const hasKmOverride = typeof kmOverride === 'number';
            const roleOverride = state.roleCategoryOverrides?.[id];
            const displayOv = state.displayOverrides?.[id] || {};
            return {
                id,
                name: effective.name,
                role: effective.role,
                emoji: effective.emoji,
                color: effective.color,
                alwaysOn: !!effective.alwaysOn,
                custom: isCustom,
                active: id === 'ceo' || state.activeAgentIds.includes(id),
                modelOverride: state.modelOverrides[id] || '',
                defaultTagline: baseDef.tagline,
                defaultSpecialty: baseDef.specialty,
                defaultPersona: baseDef.persona || '',
                tagline: override.tagline || baseDef.tagline,
                specialty: override.specialty || baseDef.specialty,
                persona: override.persona || baseDef.persona || '',
                personaOverridden: !!override.persona,
                specialtyOverridden: !!override.specialty,
                taglineOverridden: !!override.tagline,
                // 디스플레이 override — Edit 폼이 default vs current를 비교해
                // dirty 표시할 수 있도록 baseDef의 원본 값도 같이 보낸다.
                defaultName: baseDef.name,
                defaultRole: baseDef.role,
                defaultEmoji: baseDef.emoji,
                defaultColor: baseDef.color,
                nameOverridden: !!displayOv.name,
                roleOverridden: !!displayOv.role,
                emojiOverridden: !!displayOv.emoji,
                colorOverridden: !!displayOv.color,
                // 직군: effective(override 반영) + def 기본값 + override 플래그
                roleCategory: effective.roleCategory,
                defaultRoleCategory: baseDef.roleCategory,
                roleCategoryOverridden: !!roleOverride && roleOverride !== baseDef.roleCategory,
                knowledgeMixOverride: hasKmOverride ? kmOverride : null,
                effectiveKnowledgeMixWeight: hasKmOverride ? kmOverride : globalWeight,
            };
        };
        const agents = orderedIds.map(renderEntry).filter((x): x is NonNullable<ReturnType<typeof renderEntry>> => !!x);
        this._view.webview.postMessage({
            type: 'companyAgents',
            value: {
                companyName: state.companyName,
                globalKnowledgeMixWeight: globalWeight,
                agents,
                // 직군 라벨 사전 + 표시 순서. 웹뷰는 enum 값을 모르므로
                // 백엔드가 정한 라벨/순서를 같이 보내 UI 일관성을 유지.
                roleCategoryLabels: ROLE_CATEGORY_LABELS,
                roleCategoryOrder: ROLE_CATEGORY_ORDER,
            },
        });
    }

    /**
     * Drive one full company turn. Caller is the chat handler; it's already
     * persisted the user message and started a streaming bubble. We feed
     * progress events back as `companyTurnUpdate` messages so the same bubble
     * fills in as each agent finishes.
     */
    async _runCompanyTurn(userPrompt: string): Promise<void> {
        const cfg = getConfig();
        const ai = new AIService();
        const emit = (event: CompanyTurnEvent) => {
            this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event });
        };
        // Fresh AbortController per turn — the Stop button routes through
        // `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks
        // `signal.aborted` between phases and short-circuits cleanly.
        const abort = new AbortController();
        this._companyAbort = abort;
        try {
            await runCompanyTurn(userPrompt, {
                context: this._context,
                ai,
                defaultModel: cfg.defaultModel || 'gemma4:e2b',
                // Knowledge Mix wiring so company specialists *also* see the
                // user's Second Brain — same global default + per-agent
                // override semantics the chat path uses. Without this the
                // Knowledge Mix slider had no effect on company turns.
                globalKnowledgeMixWeight: cfg.knowledgeMixSecondBrainWeight ?? 50,
                brainFileBaseline: cfg.memoryLongTermFiles ?? 6,
                // Hand the dispatcher a thunk into ConnectAI's action-tag
                // executor so specialist outputs like `<create_file>` actually
                // hit disk. Without this, agents would *claim* to create
                // files while nothing happened — the exact bug we just fixed.
                executeActionTags: (text) => this._agent.executeActionTagsOnText(text),
                signal: abort.signal,
                onEvent: emit,
                // 승인 게이트 bridge — dispatcher가 호출하면 Promise를 만들어
                // resolver를 _pendingApprovals에 보관 후 await. 사용자가 카드 버튼을
                // 누르면 chatHandlers가 resolveApprovalGate(stageId, decision)을 호출
                // 하고 그 resolve가 이 await을 풀어준다.
                awaitApproval: ({ stageId }) => new Promise((resolve) => {
                    if (abort.signal.aborted) {
                        resolve({ kind: 'abort' });
                        return;
                    }
                    this._pendingApprovals.set(stageId, resolve);
                }),
            });
        } catch (e: any) {
            logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) });
            this._view?.webview.postMessage({
                type: 'error',
                value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`,
            });
        } finally {
            if (this._companyAbort === abort) this._companyAbort = undefined;
            // The webview's send button is locked into the "generating" state
            // when the user submits; it only unlocks on `streamEnd`. The
            // normal chat path posts that from inside AgentExecutor, but
            // the company turn never touches AgentExecutor, so we have to
            // post it ourselves here — otherwise the input stays disabled
            // with the red Stop button after the round completes.
            this._view?.webview.postMessage({ type: 'streamEnd' });
            void this._sendReadyStatus();
        }
    }

    /** Open the architecture doc in editor group 2. */
    async _openArchitectureDoc(): Promise<void> {
        const p = this._getActiveChronicleProject();
        if (!p || !p.architectureDocPath) return;
        try {
            const doc = await vscode.workspace.openTextDocument(p.architectureDocPath);
            await vscode.window.showTextDocument(doc, {
                viewColumn: vscode.ViewColumn.Two,
                preview: false,
            });
        } catch (e: any) {
            vscode.window.showErrorMessage(`Architecture doc 열기 실패: ${e?.message ?? e}`);
        }
    }

    /**
     * Register a debounced watcher over the project root. Only structural
     * changes regen the doc — the signature hash decides whether to write.
     * Files inside node_modules / out / dist are filtered by the glob to keep
     * the noise floor sane during normal development.
     */
    private _registerArchitectureWatcher(profile: ProjectProfile): void {
        if (!profile.projectRoot) return;
        if (this._archWatchedProjectId === profile.projectId && this._archWatcher) return;
        this._disposeArchitectureWatcher();
        if (profile.architectureAutoUpdate === false) {
            this._archWatchedProjectId = profile.projectId;
            return;
        }
        const pattern = new vscode.RelativePattern(
            profile.projectRoot,
            '{package.json,tsconfig.json,README.md,src/**,media/**,docs/**,tests/**,core_py/**,lib/**,app/**}'
        );
        const watcher = vscode.workspace.createFileSystemWatcher(pattern);
        const onChange = () => this._scheduleArchitectureRefresh();
        watcher.onDidCreate(onChange);
        watcher.onDidDelete(onChange);
        watcher.onDidChange(onChange);
        this._archWatcher = watcher;
        this._archWatchedProjectId = profile.projectId;
        logInfo('architecture: watcher registered.', { projectId: profile.projectId, root: profile.projectRoot });
    }

    private _disposeArchitectureWatcher(): void {
        try { this._archWatcher?.dispose(); } catch { /* noop */ }
        this._archWatcher = undefined;
        if (this._archWatchDebounce) { clearTimeout(this._archWatchDebounce); this._archWatchDebounce = undefined; }
        this._archWatchedProjectId = undefined;
    }

    private _scheduleArchitectureRefresh(): void {
        if (this._archWatchDebounce) clearTimeout(this._archWatchDebounce);
        // 6 s debounce: long enough that a "save file" burst settles into one
        // regen, short enough that the chip's "updated 2m ago" badge stays
        // believable.
        this._archWatchDebounce = setTimeout(async () => {
            const profile = this._getActiveChronicleProject();
            if (!profile || !profile.projectRoot || profile.architectureAutoUpdate === false) return;
            try {
                // Cheap signature check first — most file events don't change shape.
                const scan = scanProject(profile.projectRoot, profile.projectName);
                if (scan.signature === profile.architectureLastScanSignature) return;
                await this._refreshArchitecture();
            } catch (e: any) {
                logError('architecture: auto-refresh failed.', { error: e?.message ?? String(e) });
            }
        }, 6000);
    }

    _getActiveChronicleProject(): ProjectProfile | null {
        const projects = this._getChronicleProjects();
        if (projects.length === 0) return null;
        const activeId = this._context.globalState.get<string>(SidebarChatProvider.activeChronicleProjectStateKey, '');
        return projects.find(project => project.projectId === activeId) || projects[0];
    }

    async _sendChronicleProjects() {
        if (!this._view) return;
        const projects = this._getChronicleProjects();
        const active = this._getActiveChronicleProject();
        this._view.webview.postMessage({
            type: 'chronicleProjects',
            value: {
                activeProjectId: active?.projectId || '',
                projects: projects.map(project => ({
                    id: project.projectId,
                    name: project.projectName,
                    root: project.projectRoot || '',
                    recordRoot: project.recordRoot,
                    description: project.description || ''
                }))
            }
        });
    }

    async _createChronicleProject() {
        const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
        const defaultName = workspaceRoot ? path.basename(workspaceRoot) : 'New Project';

        const projectName = await vscode.window.showInputBox({
            prompt: 'Project name for Chronicle records',
            value: defaultName,
            validateInput: (value) => value.trim() ? null : 'Project name is required.'
        });
        if (!projectName) return;

        const description = await vscode.window.showInputBox({
            prompt: 'One-line project description',
            value: 'Project planning, decisions, development logs, and bug records.'
        });
        if (description === undefined) return;

        const projectRoot = await vscode.window.showInputBox({
            prompt: 'Project root path',
            value: workspaceRoot,
            validateInput: (value) => value.trim() ? null : 'Project root is required.'
        });
        if (!projectRoot) return;

        const defaultRecordRoot = path.join(projectRoot.trim(), 'docs', 'records', projectName.trim());
        const recordRoot = await vscode.window.showInputBox({
            prompt: 'Markdown record folder path',
            value: defaultRecordRoot,
            validateInput: (value) => value.trim() ? null : 'Record folder path is required.'
        });
        if (!recordRoot) return;

        const corePurpose = await vscode.window.showInputBox({
            prompt: 'Core project purpose or guardrail',
            value: 'Keep project knowledge traceable through Markdown records.'
        });
        if (corePurpose === undefined) return;

        const detailChoice = await vscode.window.showQuickPick([
            { label: 'standard', description: 'Request, question intent, decisions, planning, development, and bugs' },
            { label: 'simple', description: 'Request summary, decisions, and implementation result' },
            { label: 'detailed', description: 'Standard records plus alternatives, lessons, and retrospectives' }
        ], {
            placeHolder: 'Chronicle record detail level'
        });
        if (!detailChoice) return;

        const now = new Date().toISOString();
        const projects = this._getChronicleProjects();
        const idBase = this._slugify(projectName.trim());
        let projectId = idBase;
        let suffix = 2;
        while (projects.some(project => project.projectId === projectId)) {
            projectId = `${idBase}-${suffix++}`;
        }

        const profile: ProjectProfile = {
            projectId,
            projectName: projectName.trim(),
            projectRoot: projectRoot.trim(),
            recordRoot: recordRoot.trim(),
            description: description.trim(),
            corePurpose: corePurpose.trim(),
            targetUsers: ['Project developer'],
            avoidDirections: ['Do not mix records across projects.', 'Do not tightly couple records to core agent execution.'],
            detailLevel: detailChoice.label as ProjectProfile['detailLevel'],
            createdAt: now,
            updatedAt: now
        };

        this._chronicle.ensureProject(profile);
        const nextProjects = [...projects.filter(project => project.projectId !== profile.projectId), profile];
        await this._putChronicleProjects(nextProjects);
        await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, profile.projectId);
        await this._sendChronicleProjects();
        this.injectSystemMessage(`**[Designer Project Created]** ${profile.projectName}\n\`${profile.recordRoot}\``);
    }

    async _setActiveChronicleProject(projectId: string) {
        if (!projectId || projectId === 'new') {
            await this._createChronicleProject();
            return;
        }

        const target = this._getChronicleProjects().find(project => project.projectId === projectId);
        if (!target) return;
        await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, target.projectId);
        await this._sendChronicleProjects();
        await this._sendChronicleRecords();
        this.injectSystemMessage(`**[Designer Project Selected]** ${target.projectName}\n\`${target.recordRoot}\``);
    }

    async _openChronicleFolder() {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            vscode.window.showWarningMessage('No Chronicle project is selected.');
            return;
        }

        try {
            this._chronicle.ensureProject(profile);
            await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(profile.recordRoot));
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to open Chronicle folder: ${err.message}`);
        }
    }

    async _sendChronicleRecords() {
        if (!this._view) return;
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            this._view.webview.postMessage({ type: 'chronicleRecords', value: [] });
            return;
        }

        try {
            const records = this._chronicle.listRecords(profile).map(record => ({
                section: record.section,
                fileName: record.fileName,
                path: record.filePath,
                relativePath: record.relativePath,
                updatedAt: record.updatedAt
            }));
            this._view.webview.postMessage({ type: 'chronicleRecords', value: records });
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to list Chronicle records: ${err.message}`);
        }
    }

    async _openChronicleRecord(recordPath: string) {
        const profile = this._getActiveChronicleProject();
        if (!profile || !recordPath) {
            vscode.window.showWarningMessage('Select a Chronicle record first.');
            return;
        }

        const root = path.resolve(profile.recordRoot);
        const target = path.resolve(recordPath);
        if (!target.startsWith(`${root}${path.sep}`) || path.extname(target) !== '.md') {
            vscode.window.showErrorMessage('Selected Chronicle record path is not valid.');
            return;
        }

        if (!fs.existsSync(target)) {
            vscode.window.showErrorMessage('Selected Chronicle record no longer exists.');
            await this._sendChronicleRecords();
            return;
        }

        await openInEditorGroup(target);
    }

    async _writeChroniclePlanningFromCurrentChat() {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            vscode.window.showWarningMessage('Select or create a Designer project first.');
            return;
        }

        const history = this._agent.getHistory();
        const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
        const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
        const featureName = await vscode.window.showInputBox({
            prompt: 'Feature name for the planning document',
            value: this._summarizeForTitle(latestUser || 'Project Chronicle Feature')
        });
        if (!featureName) return;

        try {
            const createdAt = new Date().toISOString();
            const result = this._chronicle.writePlanning(profile, {
                featureName: featureName.trim(),
                purpose: 'Record the reason, scope, direction, and success criteria before implementation.',
                background: this._summarizeTextForWiki(latestAssistant || latestUser),
                userIntent: this._summarizeTextForWiki(latestUser),
                sourceRequest: latestUser || 'No user request captured in the current chat.',
                scope: [
                    'Create a project-specific planning record.',
                    'Capture user intent and implementation direction.',
                    'Keep the record independent from chat execution internals.'
                ],
                outOfScope: [
                    'Full automatic transcript capture.',
                    'External database integration.',
                    'Git automation.'
                ],
                developmentDirection: 'Use Project Chronicle as a low-dependency Markdown record layer.',
                dependencyStrategy: 'Use local filesystem writes through the independent projectChronicle module.',
                expectedValue: 'Future work can understand why this feature exists and what decisions shaped it.',
                successCriteria: [
                    'The planning document is created under the selected project record folder.',
                    'The document includes user intent, scope, out-of-scope items, and success criteria.'
                ],
                developerInstruction: 'Use this document as the implementation guardrail for the next development step.',
                createdAt
            });
            this._chronicle.appendTimeline(profile, [`Planning record created: ${result.relativePath}`], createdAt);
            vscode.window.showInformationMessage(`Chronicle planning saved: ${result.relativePath}`);
            await this._sendChronicleRecords();
            this.injectSystemMessage(`**[Chronicle Planning Saved]** \`${result.filePath}\``);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write planning record: ${err.message}`);
        }
    }

    async _writeChronicleDiscussionFromCurrentChat() {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            vscode.window.showWarningMessage('Select or create a Designer project first.');
            return;
        }

        const history = this._agent.getHistory();
        const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
        const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
        const title = await vscode.window.showInputBox({
            prompt: 'Discussion title',
            value: this._summarizeForTitle(latestUser || 'Project Discussion')
        });
        if (!title) return;

        const question = await vscode.window.showInputBox({
            prompt: 'AI question to record (optional)',
            value: ''
        });
        if (question === undefined) return;

        let questions: any[] = [];
        if (question.trim()) {
            const reason = await vscode.window.showInputBox({
                prompt: 'Why was this question asked?',
                value: 'To avoid writing records to the wrong project or making an unclear design decision.'
            });
            if (reason === undefined) return;

            const impact = await vscode.window.showInputBox({
                prompt: 'How does this question affect the decision?',
                value: 'It determines the correct project context, scope, or implementation path.'
            });
            if (impact === undefined) return;

            questions = [{
                question: question.trim(),
                reason: reason.trim(),
                expectedInformation: 'Information needed to clarify project context, scope, or decision direction.',
                impactOnDecision: impact.trim()
            }];
        }

        try {
            const createdAt = new Date().toISOString();
            const result = this._chronicle.writeDiscussion(profile, {
                title: title.trim(),
                userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
                interpretedIntent: 'Capture the discussion as a reusable project knowledge record instead of leaving it only in chat history.',
                questions,
                discussions: [
                    this._summarizeTextForWiki(latestAssistant || latestUser || 'No discussion detail captured yet.')
                ],
                decisions: [],
                createdAt
            });
            this._chronicle.appendTimeline(profile, [`Discussion record created: ${result.relativePath}`], createdAt);
            vscode.window.showInformationMessage(`Chronicle discussion saved: ${result.relativePath}`);
            await this._sendChronicleRecords();
            this.injectSystemMessage(`**[Chronicle Discussion Saved]** \`${result.filePath}\``);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write discussion record: ${err.message}`);
        }
    }

    async _writeChronicleDecisionFromInput() {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            vscode.window.showWarningMessage('Select or create a Designer project first.');
            return;
        }

        const title = await vscode.window.showInputBox({
            prompt: 'Decision title',
            value: 'Use independent Markdown record module'
        });
        if (!title) return;

        const decision = await vscode.window.showInputBox({
            prompt: 'Decision',
            value: 'Implement this behavior as an independent Project Chronicle module.'
        });
        if (decision === undefined) return;

        const reason = await vscode.window.showInputBox({
            prompt: 'Decision reason',
            value: 'To reduce coupling and keep project records portable.'
        });
        if (reason === undefined) return;

        const alternatives = await vscode.window.showInputBox({
            prompt: 'Rejected alternatives (comma-separated)',
            value: 'Integrate with Second Brain, integrate directly into Agent execution'
        });
        if (alternatives === undefined) return;

        try {
            const createdAt = new Date().toISOString();
            const adrNumber = this._chronicle.nextAdrNumber(profile);
            const result = this._chronicle.writeDecision(profile, {
                title: title.trim(),
                status: 'accepted',
                context: 'A project record needs to capture not only what changed, but why the direction was chosen.',
                decision: decision.trim(),
                reason: reason.trim(),
                alternatives: alternatives.split(',').map(item => item.trim()).filter(Boolean),
                consequences: [
                    'Records can evolve independently from chat and agent internals.',
                    'Future automation can emit chronicle events without owning the core execution path.'
                ],
                createdAt
            }, adrNumber);
            this._chronicle.appendTimeline(profile, [`Decision record created: ${result.relativePath}`], createdAt);
            vscode.window.showInformationMessage(`Chronicle decision saved: ${result.relativePath}`);
            await this._sendChronicleRecords();
            this.injectSystemMessage(`**[Chronicle Decision Saved]** \`${result.filePath}\``);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write decision record: ${err.message}`);
        }
    }

    async _writeChronicleDevelopmentFromCurrentChat() {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            vscode.window.showWarningMessage('Select or create a Designer project first.');
            return;
        }

        const history = this._agent.getHistory();
        const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
        const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
        const featureName = await vscode.window.showInputBox({
            prompt: 'Feature name for the development log',
            value: this._summarizeForTitle(latestUser || 'Implementation Log')
        });
        if (!featureName) return;

        try {
            const createdAt = new Date().toISOString();
            const result = this._chronicle.writeDevelopmentLog(profile, {
                featureName: featureName.trim(),
                purpose: 'Record the actual implementation outcome for later maintenance.',
                implementationSummary: this._summarizeTextForWiki(latestAssistant || 'Implementation summary was not captured from chat yet.'),
                architecture: 'Project Chronicle records are written through an independent Markdown module.',
                changedFiles: ['Capture exact changed files after verification.'],
                dependencyNotes: 'Keep dependencies limited to TypeScript, Node fs/path, and VS Code extension APIs.',
                bugs: [],
                lessons: [
                    'Write implementation notes as soon as a stable development step finishes.',
                    'Keep generated records project-specific.'
                ],
                createdAt
            });
            this._chronicle.appendTimeline(profile, [`Development log created: ${result.relativePath}`], createdAt);
            vscode.window.showInformationMessage(`Chronicle development log saved: ${result.relativePath}`);
            await this._sendChronicleRecords();
            this.injectSystemMessage(`**[Chronicle Development Saved]** \`${result.filePath}\``);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write development record: ${err.message}`);
        }
    }

    async _writeChronicleBugFromInput() {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            vscode.window.showWarningMessage('Select or create a Designer project first.');
            return;
        }

        const title = await vscode.window.showInputBox({
            prompt: 'Bug title',
            value: 'record-generation-issue'
        });
        if (!title) return;

        const symptom = await vscode.window.showInputBox({
            prompt: 'Bug symptom',
            value: 'Describe what failed or looked wrong.'
        });
        if (symptom === undefined) return;

        const cause = await vscode.window.showInputBox({
            prompt: 'Bug cause',
            value: 'Cause is not confirmed yet.'
        });
        if (cause === undefined) return;

        const fix = await vscode.window.showInputBox({
            prompt: 'Fix',
            value: 'Describe the fix or mitigation.'
        });
        if (fix === undefined) return;

        try {
            const createdAt = new Date().toISOString();
            const bugNumber = this._chronicle.nextBugNumber(profile);
            const result = this._chronicle.writeBug(profile, {
                title: title.trim(),
                symptom: symptom.trim(),
                cause: cause.trim(),
                fix: fix.trim(),
                prevention: 'Validate project selection, record path, and write permissions before generating files.',
                createdAt
            }, bugNumber);
            this._chronicle.appendTimeline(profile, [`Bug record created: ${result.relativePath}`], createdAt);
            vscode.window.showInformationMessage(`Chronicle bug record saved: ${result.relativePath}`);
            await this._sendChronicleRecords();
            this.injectSystemMessage(`**[Chronicle Bug Saved]** \`${result.filePath}\``);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write bug record: ${err.message}`);
        }
    }

    async _writeChronicleRetrospectiveFromInput() {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            vscode.window.showWarningMessage('Select or create a Designer project first.');
            return;
        }

        const title = await vscode.window.showInputBox({
            prompt: 'Retrospective title',
            value: 'Project Chronicle Guard iteration'
        });
        if (!title) return;

        const summary = await vscode.window.showInputBox({
            prompt: 'Work summary',
            value: 'Completed an incremental development step and recorded the outcome.'
        });
        if (summary === undefined) return;

        const wentWell = await vscode.window.showInputBox({
            prompt: 'What went well? (comma-separated)',
            value: 'Kept the feature independent, Generated Markdown records, Preserved project context'
        });
        if (wentWell === undefined) return;

        const toImprove = await vscode.window.showInputBox({
            prompt: 'What should improve? (comma-separated)',
            value: 'More automatic question intent capture, Richer record editing UI'
        });
        if (toImprove === undefined) return;

        const nextActions = await vscode.window.showInputBox({
            prompt: 'Next actions (comma-separated)',
            value: 'Add tests, Improve Designer UI, Add event-based record capture'
        });
        if (nextActions === undefined) return;

        try {
            const createdAt = new Date().toISOString();
            const result = this._chronicle.writeRetrospective(profile, {
                title: title.trim(),
                summary: summary.trim(),
                wentWell: wentWell.split(',').map(item => item.trim()).filter(Boolean),
                toImprove: toImprove.split(',').map(item => item.trim()).filter(Boolean),
                nextActions: nextActions.split(',').map(item => item.trim()).filter(Boolean),
                createdAt
            });
            this._chronicle.appendTimeline(profile, [`Retrospective created: ${result.relativePath}`], createdAt);
            vscode.window.showInformationMessage(`Chronicle retrospective saved: ${result.relativePath}`);
            await this._sendChronicleRecords();
            this.injectSystemMessage(`**[Chronicle Retrospective Saved]** \`${result.filePath}\``);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write retrospective record: ${err.message}`);
        }
    }

    async _autoWriteChronicleAfterPrompt() {
        const history = this._agent.getHistory();
        const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
        const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
        const profile = this._getChronicleProjectForConversation(latestUser) || this._getActiveChronicleProject();
        if (!profile) return;

        const recordType = this._inferAutoChronicleRecordType(latestUser, latestAssistant);
        if (!recordType) return;

        const signature = [
            profile.projectId,
            recordType,
            this._summarizeTextForWiki(latestUser).slice(0, 240),
            this._summarizeTextForWiki(latestAssistant).slice(0, 240)
        ].join('|');
        const lastSignature = this._context.globalState.get<string>(SidebarChatProvider.lastAutoChronicleSignatureStateKey, '');
        if (signature === lastSignature) return;

        try {
            this._chronicle.ensureProject(profile);
            const createdAt = new Date().toISOString();
            const title = this._summarizeForTitle(latestUser || latestAssistant || 'Project Chronicle Auto Record');
            const summary = this._summarizeTextForWiki(latestAssistant || latestUser);
            let result;

            if (recordType === 'bug') {
                const bugNumber = this._chronicle.nextBugNumber(profile);
                result = this._chronicle.writeBug(profile, {
                    title,
                    symptom: this._summarizeTextForWiki(latestUser || 'Issue was detected during the conversation.'),
                    cause: 'Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.',
                    fix: summary,
                    prevention: 'Keep automatic records tied to the active project and verify the relevant test or reproduction path.',
                    createdAt
                }, bugNumber);
            } else if (recordType === 'planning') {
                result = this._chronicle.writePlanning(profile, {
                    featureName: title,
                    purpose: 'Capture the current planning or architecture direction before implementation continues.',
                    background: summary,
                    userIntent: this._summarizeTextForWiki(latestUser),
                    sourceRequest: latestUser || 'No user request captured in the current chat.',
                    scope: ['Continue from the active project conversation.', 'Use the selected project record folder automatically.'],
                    outOfScope: ['Manual record type selection.', 'Blocking the user with record-writing prompts.'],
                    developmentDirection: summary,
                    dependencyStrategy: 'Prefer existing project modules and local Markdown records.',
                    expectedValue: 'Future work can resume with the latest project intent and reasoning preserved.',
                    successCriteria: ['The record is saved automatically after a meaningful project turn.', 'The record stays under the active project.'],
                    developerInstruction: 'Use this record as lightweight context for the next development or review pass.',
                    createdAt
                });
            } else if (recordType === 'decision') {
                const adrNumber = this._chronicle.nextAdrNumber(profile);
                result = this._chronicle.writeDecision(profile, {
                    title,
                    status: 'accepted',
                    context: this._summarizeTextForWiki(latestUser),
                    decision: summary,
                    reason: 'Captured automatically because the conversation contained decision-oriented language.',
                    alternatives: [],
                    consequences: ['Future prompts should treat this as project context unless the user changes direction.'],
                    createdAt
                }, adrNumber);
            } else if (recordType === 'development') {
                result = this._chronicle.writeDevelopmentLog(profile, {
                    featureName: title,
                    purpose: 'Record the implementation or verification outcome from the current conversation.',
                    implementationSummary: summary,
                    architecture: 'Captured automatically from the assistant response and active project context.',
                    changedFiles: this._extractChangedFilesFromText(latestAssistant),
                    dependencyNotes: 'No new dependency note was captured automatically.',
                    bugs: [],
                    lessons: ['Automatic project records should be generated in the background when the turn contains durable project knowledge.'],
                    createdAt
                });
            } else {
                result = this._chronicle.writeDiscussion(profile, {
                    title,
                    userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
                    interpretedIntent: 'Capture a meaningful project discussion automatically instead of requiring manual record selection.',
                    questions: [],
                    discussions: [summary],
                    decisions: [],
                    createdAt
                });
            }

            this._chronicle.appendTimeline(profile, [`Auto ${recordType} record created: ${result.relativePath}`], createdAt);
            await this._context.globalState.update(SidebarChatProvider.lastAutoChronicleSignatureStateKey, signature);
            await this._sendChronicleRecords();
            vscode.window.setStatusBarMessage(`Astra: Chronicle auto-saved ${recordType}`, 3500);
        } catch (err: any) {
            logError('Automatic Chronicle record write failed.', { error: err?.message || String(err), recordType });
        }
    }

    _getChronicleProjectForConversation(text: string): ProjectProfile | null {
        const projectPath = this._extractLocalProjectPath(text);
        if (!projectPath) return null;

        const projects = this._getChronicleProjects();
        const resolvedPath = path.resolve(projectPath);
        const existing = projects.find(project => {
            const root = project.projectRoot ? path.resolve(project.projectRoot) : '';
            const recordRoot = path.resolve(project.recordRoot);
            return root === resolvedPath || recordRoot.startsWith(`${resolvedPath}${path.sep}`);
        });
        if (existing) return existing;

        const projectName = path.basename(resolvedPath) || 'Current Project';
        const now = new Date().toISOString();
        return {
            projectId: this._slugify(projectName),
            projectName,
            projectRoot: resolvedPath,
            recordRoot: path.join(resolvedPath, 'docs', 'records', projectName),
            description: 'Auto-detected from the local project path in the conversation.',
            corePurpose: 'Capture project direction, architecture discussion, decisions, and development notes as Markdown.',
            targetUsers: ['Project developer'],
            avoidDirections: ['Do not mix records across projects.'],
            detailLevel: 'standard',
            createdAt: now,
            updatedAt: now
        };
    }

    _extractLocalProjectPath(text: string): string | null {
        const match = text.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i);
        if (!match) return null;
        const candidate = match[0].replace(/[.,;:)\]]+$/, '');
        try {
            if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
                return candidate;
            }
        } catch {
            return null;
        }
        return null;
    }

    _inferAutoChronicleRecordType(userText: string, assistantText: string): 'planning' | 'discussion' | 'decision' | 'development' | 'bug' | null {
        const combined = `${userText}\n${assistantText}`;
        if (!combined.trim()) return null;
        if (/(기록하지마|저장하지마|no\s+record|do\s+not\s+record)/i.test(combined)) return null;
        if (!/(프로젝트|코드|아키텍처|설계|개선|수정|구현|테스트|검증|이슈|문제|버그|오류|끊김|결정|방향|기록|지식|review|architecture|implement|fix|bug|issue|test|decision)/i.test(combined)) {
            return null;
        }
        if (/(버그|오류|에러|이슈|문제|끊김|안\s*됨|실패|bug|error|issue|failed|failure)/i.test(userText)) {
            return 'bug';
        }
        if (/(수정 완료|개선 완료|구현 완료|패치|테스트.*통과|검증.*완료|변경.*파일|compile|jest|tsc|passed|implemented|fixed)/i.test(assistantText)) {
            return 'development';
        }
        if (/(결정|확정|채택|방향은|하기로|하지 않기로|decision|decide|accepted)/i.test(combined)) {
            return 'decision';
        }
        if (/(계획|설계|아키텍처|조사|방향|로드맵|mvp|planning|architecture|roadmap|design)/i.test(userText)) {
            return 'planning';
        }
        if (/(개선|수정|구현|테스트|검증|패킹|compile|jest|tsc|implement|fix|test|verify)/i.test(combined)) {
            return 'development';
        }
        return 'discussion';
    }

    _extractChangedFilesFromText(text: string): string[] {
        const files = new Set<string>();
        for (const match of text.matchAll(/`([^`\n]+\.(?:ts|tsx|js|jsx|json|md|css|html|py|yml|yaml))`/gi)) {
            files.add(match[1].trim());
        }
        return files.size > 0 ? Array.from(files).slice(0, 12) : ['No explicit changed file list was captured automatically.'];
    }

    async _writeChronicleRecord(recordType: string) {
        switch (recordType) {
            case 'planning':
                await this._writeChroniclePlanningFromCurrentChat();
                break;
            case 'discussion':
                await this._writeChronicleDiscussionFromCurrentChat();
                break;
            case 'decision':
                await this._writeChronicleDecisionFromInput();
                break;
            case 'development':
                await this._writeChronicleDevelopmentFromCurrentChat();
                break;
            case 'bug':
                await this._writeChronicleBugFromInput();
                break;
            case 'retrospective':
                await this._writeChronicleRetrospectiveFromInput();
                break;
            default:
                vscode.window.showWarningMessage('Select a Chronicle record type first.');
        }
    }

    _getAgentsDir(): string {
        // 1) Explicit config override (works on any OS — useful on Windows or for skills outside the workspace).
        const configured = (vscode.workspace.getConfiguration('g1nation').get<string>('agentSkillsPath', '') || '').trim();
        const expanded = configured.startsWith('~/') || configured === '~'
            ? path.join(os.homedir(), configured.slice(1).replace(/^[\\/]/, ''))
            : configured;
        if (expanded && path.isAbsolute(expanded)) {
            if (!fs.existsSync(expanded)) {
                try { fs.mkdirSync(expanded, { recursive: true }); } catch { /* fall through to workspace */ }
            }
            if (fs.existsSync(expanded)) return expanded;
        }
        // 2) Default: <workspace>/.agent/skills
        const workspaceFolders = vscode.workspace.workspaceFolders;
        if (workspaceFolders) {
            const localPath = path.join(workspaceFolders[0].uri.fsPath, '.agent', 'skills');
            if (!fs.existsSync(localPath)) {
                fs.mkdirSync(localPath, { recursive: true });
            }
            return localPath;
        }
        return '';
    }

    async _sendAgentsList() {
        if (!this._view) return;
        const dir = this._getAgentsDir();
        const agents = [];
        if (dir && fs.existsSync(dir)) {
            const files = fs.readdirSync(dir);
            for (const f of files) {
                if (f.endsWith('.md')) {
                    agents.push({ name: f.replace('.md', ''), path: path.join(dir, f) });
                }
            }
        }
        const lastPath = this._context.globalState.get<string>(SidebarChatProvider.lastAgentStateKey, 'none');
        this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath });
        void this._sendReadyStatus();
    }

    async _handleProactiveSuggestion(context: string) {
        if (!this._view) return;

        let suggestion = '';
        switch (context) {
            case 'settings_exploration':
                suggestion = '💡 **Tip:** 모델 설정을 최적화하여 답변 속도를 2배 이상 높일 수 있습니다. 설정에서 `Max Context Size`를 조정해보세요!';
                break;
            case 'brain_sync_exploration':
                suggestion = '🧠 **Knowledge Sync:** 최근에 수정한 파일이 지식 베이스에 반영되지 않았나요? 지금 동기화 버튼을 눌러 최신 정보를 업데이트하세요.';
                break;
            case 'agent_selection_exploration':
                suggestion = '🤖 **Agent Skills:** 특정 언어나 프레임워크에 특화된 에이전트 스킬을 선택하면 더 정확한 코드를 생성할 수 있습니다.';
                break;
            default:
                suggestion = '💡 새로운 기능을 발견하셨나요? 궁금한 점이 있다면 언제든 물어보세요!';
        }

        this._view.webview.postMessage({ type: 'streamStart' });
        this._view.webview.postMessage({ type: 'streamChunk', value: `\n\n> [!TIP]\n> ${suggestion}\n` });
        this._view.webview.postMessage({ type: 'streamEnd' });
    }

    async _createAgent() {
        const name = await vscode.window.showInputBox({
            prompt: 'Name of the new Agent (e.g., frontend_expert)',
            placeHolder: 'Agent name...'
        });
        if (!name) return;

        const safeName = name.trim().replace(/[^a-zA-Z0-9_\-\u3131-\uD79D가-힣]/g, '_');
        if (!safeName) return;

        const dir = this._getAgentsDir();
        if (!dir) {
            vscode.window.showErrorMessage('Agent directory could not be determined.');
            return;
        }

        const filePath = path.join(dir, `${safeName}.md`);
        if (!fs.existsSync(filePath)) {
            fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\n\nAdd your instructions here...\n`, 'utf8');
        }

        await openInEditorGroup(filePath);
        await this._sendAgentsList();
    }

    async _sendAgentContent(agentPath: string) {
        if (!this._view || !agentPath || agentPath === 'none') return;
        if (fs.existsSync(agentPath)) {
            const content = fs.readFileSync(agentPath, 'utf8');
            const negativePrompt = this._context.globalState.get<string>(`negativePrompt:${agentPath}`, '');
            this._view.webview.postMessage({ 
                type: 'agentContent', 
                value: content,
                negativePrompt: negativePrompt
            });
            await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, agentPath);
        }
    }

    async _updateAgent(agentPath: string, content: string, negativePrompt?: string) {
        if (!agentPath || agentPath === 'none') return;
        try {
            fs.writeFileSync(agentPath, content, 'utf8');
            if (negativePrompt !== undefined) {
                await this._context.globalState.update(`negativePrompt:${agentPath}`, negativePrompt);
            }
            vscode.window.showInformationMessage('Agent skill updated successfully.');
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to update agent: ${err.message}`);
        }
    }

    async _deleteAgent(agentPath: string) {
        if (!agentPath || agentPath === 'none') return;

        try {
            const agentsDir = path.resolve(this._getAgentsDir());
            const targetPath = path.resolve(agentPath);
            if (!targetPath.startsWith(`${agentsDir}${path.sep}`) || path.extname(targetPath) !== '.md') {
                vscode.window.showErrorMessage('Selected agent skill path is not valid.');
                return;
            }

            if (!fs.existsSync(targetPath)) {
                await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none');
                await this._sendAgentsList();
                return;
            }

            const confirm = await vscode.window.showWarningMessage(
                `Delete agent skill "${path.basename(targetPath, '.md')}"?`,
                { modal: true },
                'Delete Skill'
            );
            if (confirm !== 'Delete Skill') return;

            fs.unlinkSync(targetPath);
            await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none');
            await this._sendAgentsList();
            this._view?.webview.postMessage({ type: 'agentDeleted' });
            vscode.window.showInformationMessage('Agent skill deleted successfully.');
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to delete agent: ${err.message}`);
        }
    }

    async _handlePrompt(data: any) {
        if (!this._view) return;

        const { value, model, internet, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data;
        this._currentNegativePrompt = negativePrompt || '';
        const selectedBrainId = typeof brainProfileId === 'string' && brainProfileId && brainProfileId !== 'new'
            ? brainProfileId
            : getActiveBrainProfile().id;
        this._currentSessionBrainId = selectedBrainId;
        
        let agentSkillContext = undefined;
        // Per-agent model override: if the active agent has a pinned model in the
        // knowledge map, it wins over the model the webview just sent. Falls back
        // to the incoming `model` (which is the global default the user picked).
        let effectiveModel: string = typeof model === 'string' ? model : '';
        if (agentFile && agentFile !== 'none' && fs.existsSync(agentFile)) {
            const fileContent = fs.readFileSync(agentFile, 'utf8');
            // Guard: a freshly-created agent still has only the placeholder template
            // ("# Agent Persona: …\n\nAdd your instructions here…"). Treating that as a real
            // agent prompt just confuses the model — fall back to normal mode and tell the user.
            const body = fileContent.replace(/^﻿?#\s*Agent\s*Persona\s*:.*$/im, '').trim();
            const isPlaceholder = !body || /^add your instructions here/i.test(body);
            if (isPlaceholder) {
                logInfo('Selected agent has no real instructions — running without agent mode.', { agentFile });
                this._view?.webview.postMessage({ type: 'lmStudioError', value: '선택한 에이전트에 내용이 없습니다 — 에이전트 프롬프트를 작성한 뒤 다시 시도하세요. (이번 응답은 에이전트 없이 처리합니다)' });
            } else {
                agentSkillContext = fileContent;
                // Merge in any external skill .md files the user has mapped to this agent. We concatenate
                // into the same agentSkillContext blob so the rest of the pipeline (agent.ts, agent-mode
                // override) treats them identically to the agent's own .md — no further changes needed.
                try {
                    const entry = getOrCreateAgentEntry(agentFile);
                    const bundle = loadExternalSkills(entry.skillFolders);
                    const block = formatSkillsAsPromptBlock(bundle);
                    if (block) {
                        agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`;
                    }
                    // Apply the per-agent model override, if any.
                    const pinned = entry.model?.trim();
                    if (pinned && pinned !== effectiveModel) {
                        logInfo('Per-agent model override applied.', {
                            agent: entry.name,
                            requested: effectiveModel,
                            pinned,
                        });
                        effectiveModel = pinned;
                        // Inform the webview so its UI can reflect the model that's actually in use.
                        this._view?.webview.postMessage({
                            type: 'agentModelOverride',
                            value: { agent: entry.name, model: pinned },
                        });
                    }
                } catch (e: any) {
                    logError('External skill load failed.', { error: e?.message || String(e) });
                }
            }
        }

        const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;

        // Project Architecture activation (Feature 2): if the user just said
        // "나 X 프로젝트 진행할 거야" / mentioned an absolute path / etc., switch
        // to that project's mode before assembling the prompt. Best-effort:
        // failures here never block the actual answer.
        if (typeof value === 'string' && value.trim().length > 0) {
            try {
                await this._tryActivateArchitectureFromText(value);
            } catch (e: any) {
                logError('architecture: intent detection failed.', { error: e?.message ?? String(e) });
            }
        }
        // Re-resolve the active subproject from the currently-focused editor.
        // Without this, switching between subprojects (e.g. ConnectAI →
        // Datacollector) inside one VS Code window keeps loading the previous
        // subproject's architecture into the prompt.
        try {
            await this._ensureActiveProjectForWorkspace();
        } catch (e: any) {
            logError('architecture: workspace resync failed.', { error: e?.message ?? String(e) });
        }
        const projectArchitectureContext = this._buildProjectArchitectureContext();

        // [File Processing v2] 파일 타입별 분류 처리
        let processedPrompt = value || '';
        let imageFiles: any[] | undefined = undefined;

        if (files && Array.isArray(files) && files.length > 0) {
            const textContents: string[] = [];
            const images: any[] = [];

            for (const file of files) {
                const name = file.name?.toLowerCase() || '';
                const type = file.type || '';

                if (name.endsWith('.pdf') || type === 'application/pdf') {
                    // PDF: 서버사이드 텍스트 추출 (pdf-parse v2 API) + Vision 폴백
                    let pdfTextOk = false;
                    try {
                        const { PDFParse } = require('pdf-parse');
                        const rawBuffer = Buffer.from(file.data, 'base64');
                        const uint8 = new Uint8Array(rawBuffer.buffer, rawBuffer.byteOffset, rawBuffer.byteLength);
                        const parser = new PDFParse(uint8);
                        await parser.load();
                        const textResult = await parser.getText();
                        const extracted = (typeof textResult === 'string' ? textResult : (textResult?.text || '')).trim();
                        const cleanText = extracted.replace(/\n*-- \d+ of \d+ --\n*/g, '\n').trim();
                        if (cleanText && cleanText.length > 30) {
                            textContents.push(`\n[PDF: ${file.name}]\n${cleanText}`);
                            logInfo(`PDF text extracted successfully.`, { fileName: file.name, chars: cleanText.length });
                            pdfTextOk = true;
                        }

                        // [Vision Fallback] 텍스트가 비어있으면 페이지 이미지 추출 -> Vision 모델에 전달
                        if (!pdfTextOk) {
                            logInfo(`PDF has no text layer. Extracting page screenshots for vision analysis.`, { fileName: file.name });
                            const screenshots = await parser.getScreenshot({ page: 1 });
                            if (screenshots?.pages && screenshots.pages.length > 0) {
                                const maxPages = Math.min(screenshots.pages.length, 8); // 메모리 보호: 최대 8페이지
                                for (let i = 0; i < maxPages; i++) {
                                    const page = screenshots.pages[i];
                                    if (page?.data) {
                                        const pageBase64 = Buffer.from(page.data).toString('base64');
                                        images.push({
                                            name: `${file.name}_page${i + 1}.png`,
                                            type: 'image/png',
                                            data: pageBase64
                                        });
                                    }
                                }
                                textContents.push(`\n[PDF: ${file.name}]\n(이미지 기반 PDF ${screenshots.total}페이지 중 ${maxPages}페이지를 이미지로 추출하여 Vision 분석합니다. 각 페이지 이미지를 참조하여 문서의 내용을 상세히 분석하고 한국어로 정리하세요.)`);
                                logInfo(`PDF vision fallback: extracted ${maxPages} page screenshots.`, { fileName: file.name, totalPages: screenshots.total });
                                pdfTextOk = true; // Vision 분석으로 처리 완료
                            }
                        }
                    } catch (pdfError: any) {
                        logError(`PDF processing failed.`, { fileName: file.name, error: pdfError?.message || String(pdfError) });
                    }

                    // 최종 폴백: 텍스트도 없고 이미지 추출도 실패한 경우
                    if (!pdfTextOk) {
                        textContents.push(`\n[PDF: ${file.name}]\n(PDF 분석에 실패했습니다. 이 파일을 텍스트로 변환하여 다시 시도해주세요.)`);
                    }
                } else if (
                    type.startsWith('text/') ||
                    type === 'application/json' ||
                    /\.(txt|md|csv|json|js|ts|jsx|tsx|html|css|py|java|rs|go|yaml|yml|xml|toml|sql|sh)$/i.test(name)
                ) {
                    // 텍스트 파일: base64 디코딩
                    try {
                        const decoded = Buffer.from(file.data, 'base64').toString('utf-8');
                        textContents.push(`\n[FILE: ${file.name}]\n\`\`\`\n${decoded}\n\`\`\``);
                    } catch (decodeError: any) {
                        logError(`Text file decode failed.`, { fileName: file.name, error: decodeError?.message });
                        textContents.push(`\n[FILE: ${file.name}]\n(디코딩 오류)`);
                    }
                } else if (type.startsWith('image/')) {
                    // 이미지: 기존 vision 방식 유지
                    images.push(file);
                } else {
                    // 미지원 타입: 파일명만 기록
                    textContents.push(`\n[ATTACHMENT: ${file.name}]\n(지원하지 않는 파일 형식: ${type || 'unknown'})`);
                }
            }

            // 추출된 텍스트를 프롬프트에 주입
            if (textContents.length > 0) {
                processedPrompt = `${processedPrompt}\n\n--- 첨부 파일 내용 ---${textContents.join('\n')}`;
            }
            imageFiles = images.length > 0 ? images : undefined;
        }

        try {
            await this._agent.handlePrompt(processedPrompt, effectiveModel || model, {
                internetEnabled: internet,
                visionContent: imageFiles,
                agentSkillContext,
                agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined,
                negativePrompt,
                designerContext,
                projectArchitectureContext: projectArchitectureContext || undefined,
                secondBrainTraceEnabled: secondBrainTrace !== false,
                secondBrainTraceDebug: !!secondBrainTraceDebug,
                brainProfileId: selectedBrainId
            });
        } catch (error: any) {
            logError('Prompt handling failed in sidebar provider.', { error: error?.message || String(error), promptPreview: summarizeText(value || '', 200) });
            this._view.webview.postMessage({ type: 'error', value: error.message });
        } finally {
            void this._sendReadyStatus();
        }
    }

    _buildDesignerGuardContext(): string {
        return buildProjectChronicleGuardContext(this._getActiveChronicleProject());
    }

    async _sendModels(force: boolean = false) {
        if (!this._view) return;
        if (this._modelDiscoveryInFlight) {
            logInfo('Model discovery already in progress, skipping.');
            return;
        }
        this._modelDiscoveryInFlight = true;
        try {
            const config = getConfig();
            const url = config.ollamaUrl;
            let defaultModel = config.defaultModel;
            let models: string[] = [];
            let online = false;

            const cache = this._modelsCache;
            const cacheFresh = !!cache
                && cache.url === url
                && (Date.now() - cache.fetchedAt) < SidebarChatProvider.MODELS_CACHE_TTL_MS;

            if (!force && cacheFresh && cache) {
                models = cache.models.slice();
                online = cache.online;
                this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } });
            } else {
                const engine = resolveEngine(url);  // 단일 엔진만
                const modelsUrl = buildApiUrl(url, engine, 'models');
                try {
                    logInfo('Model discovery started.', { engine, modelsUrl, force });
                    const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) });
                    const rawText = await res.text();
                    if (!res.ok) {
                        logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) });
                    } else {
                        const data = rawText ? JSON.parse(rawText) as any : {};
                        models = engine === 'lmstudio'
                            ? (data.data || []).map((m: any) => m.id)
                            : (data.models || []).map((m: any) => m.name);

                        if (models.length > 0) {
                            logInfo('Model discovery succeeded.', { engine, count: models.length, modelsPreview: models.slice(0, 5) });
                        }
                    }
                } catch (e: any) {
                    logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) });
                }

                online = models.length > 0;
                this._modelsCache = { url, models: models.slice(), online, fetchedAt: Date.now() };
                this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } });
            }

            if (models.length === 0) {
                models = defaultModel ? [defaultModel] : [];
            }

            const baseModel = defaultModel?.replace(/:\d+$/, '');
            if (baseModel && baseModel !== defaultModel && models.includes(baseModel)) {
                defaultModel = baseModel;
                await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
            }

            if (models.length > 0 && !defaultModel) {
                // [State Persistence Fix v2] defaultModel이 완전히 비어있을 때만 첫 번째 모델로 설정
                defaultModel = models[0];
                await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
            } else if (models.length > 0 && defaultModel && !models.includes(defaultModel)) {
                // [State Persistence Fix v2] 저장된 모델이 로컬 엔진 목록에 없는 경우:
                // 강제 리셋하지 않고, 저장된 모델을 목록 선두에 추가하여 사용자 선택을 보존
                logInfo('Saved model not in local engine list. Preserving user selection.', { saved: defaultModel, localModels: models.slice(0, 3) });
                models.unshift(defaultModel);
            }

            const defaultIdx = models.indexOf(defaultModel);
            if (defaultIdx > 0) {
                models.splice(defaultIdx, 1);
                models.unshift(defaultModel);
            }

            let loadedModels: string[] = [];
            if (resolveEngine(url) === 'lmstudio' && this._lmStudio) {
                try {
                    loadedModels = await this._lmStudio.loadedModels();
                } catch (e) {
                    logInfo('LM Studio loadedModels probe failed (non-fatal).', { error: (e as any)?.message ?? String(e) });
                }
            }

            this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel, loadedModels } });
        } catch (err) {
            logError('Model list update failed.', err);
        } finally {
            this._modelDiscoveryInFlight = false;
        }
        void this._sendReadyStatus();
    }

    static _htmlTemplateCache: string | undefined;

    _getHtml(webview: vscode.Webview): string {
        if (!SidebarChatProvider._htmlTemplateCache) {
            const tplPath = path.join(this._extensionUri.fsPath, 'media', 'sidebar.html');
            SidebarChatProvider._htmlTemplateCache = fs.readFileSync(tplPath, 'utf8');
        }
        const mediaRoot = vscode.Uri.joinPath(this._extensionUri, 'media');
        const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.css')).toString();
        const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.js')).toString();
        return SidebarChatProvider._htmlTemplateCache
            .replace('__STYLES_URI__', stylesUri)
            .replace('__SCRIPT_URI__', scriptUri);
    }
}

/**
 * Adapter that makes a {@link vscode.WebviewPanel} quack like a
 * {@link vscode.WebviewView}, so providers written against the view API can
 * mount inside an editor column without their internals knowing the difference.
 *
 * `onDidChangeVisibility` is synthesized from `onDidChangeViewState` — panels
 * fire that event for both visibility *and* column moves, but the listener
 * here only re-fires when the visible flag actually toggles.
 */
export function wrapPanelAsView(panel: vscode.WebviewPanel): vscode.WebviewView {
    const visibilityEmitter = new vscode.EventEmitter<void>();
    let _lastVisible = panel.visible;
    panel.onDidChangeViewState(() => {
        if (panel.visible !== _lastVisible) {
            _lastVisible = panel.visible;
            visibilityEmitter.fire();
        }
    });
    panel.onDidDispose(() => visibilityEmitter.dispose());
    const adapter: any = {
        viewType: panel.viewType,
        webview: panel.webview,
        get visible() { return panel.visible; },
        get title() { return panel.title; },
        set title(v: string | undefined) { panel.title = v ?? ''; },
        description: undefined as string | undefined,
        badge: undefined as vscode.ViewBadge | undefined,
        onDidChangeVisibility: visibilityEmitter.event,
        onDidDispose: panel.onDidDispose,
        show(preserveFocus?: boolean) {
            panel.reveal(panel.viewColumn ?? vscode.ViewColumn.Three, preserveFocus);
        },
    };
    return adapter as vscode.WebviewView;
}
