import { useCoreServices } from '@learnitive/context';
import { isLearnitiveEditableTextPath, isProjectCodeTextPath, projectFilePath } from '@learnitive/project-storage';
import type { ProjectAiCompileToolExecutor, ProjectAiLatexSnippet, ProjectAiProposalOperation, ProjectAiSandboxToolExecutor } from '@learnitive/service-core';
import { Button, toast } from '@learnitive/ui-components';
import { WsPath } from '@learnitive/ws-path';
import { useAtomValue } from 'jotai';
import {
  Check,
  ChevronDown,
  ChevronUp,
  Copy,
  FileInput,
  Settings2,
  Trash2,
  X,
} from 'lucide-react';
import React from 'react';
import { AiComposer, AiConversationShell, AiMessageBubble, type AiConversationTab } from './ai-conversation-shell';
import { openAiSettingsDialog } from './ai-settings-events';
import { PaperMemoryErrorBoundary } from './paper-memory-error-boundary';
import { PaperMemoryPanel } from './paper-memory-panel';
import { flushProjectEditorDrafts, requestProjectEditorReveal } from '../project-compile-ui';
import type { PaperMemoryManuscriptActionRequest, PaperMemoryManuscriptSelection, PaperMemoryOpenPaperRequest } from '../paper-memory-manuscript';
import { getProjectSandboxProcessLogs, listProjectSandboxProcesses, projectSandboxPreviewUrl, runProjectSandboxCommand, startProjectSandboxProcess, stopProjectSandboxProcess } from '../saas/saas-api';
import { synchronizeSandboxProject } from '../saas/sandbox-project-sync';
import { recentPreviewDiagnostics } from '../saas/preview-diagnostics';

function latexTokenClass(token: string): string {
  if (token.startsWith('%')) return 'text-muted-foreground italic';
  if (token.startsWith('\\')) return 'text-primary font-semibold';
  if (/^[{}[\]&]$/u.test(token)) return 'text-foreground font-semibold';
  return '';
}

function HighlightedLatex({ latex }: { latex: string }) {
  const tokens = latex.split(/(%.+$|\\[A-Za-z@]+\*?|\\.|[{}[\]&])/gmu);
  let sourceOffset = 0;
  const highlightedTokens = tokens.flatMap((token) => {
    if (!token) return [];
    const start = sourceOffset;
    sourceOffset += token.length;
    return [{ key: `${start}:${token.length}:${token.slice(0, 12)}`, token }];
  });
  return (
    <pre className="max-h-72 overflow-auto whitespace-pre rounded-md bg-background p-3 text-[12px] leading-5">
      <code>
        {highlightedTokens.map(({ key, token }) => (
          <span key={key} className={latexTokenClass(token)}>{token}</span>
        ))}
      </code>
    </pre>
  );
}

function visibleAssistantContent(content: string): string {
  const trimmed = content.trim();
  const unwrapped = trimmed.replace(/^```json\s*/iu, '').replace(/```$/u, '').trim();
  if (
    /^\{/u.test(unwrapped) &&
    /["']?(?:toolCalls|tool_calls|toolCall|tool_call)["']?\s*:/u.test(unwrapped)
  ) {
    return 'The AI returned an internal tool response that could not be formatted safely. Please retry the request.';
  }
  return content;
}

function assistantProseToLatex(content: string): string {
  const escapes: Record<string, string> = {
    '\\': '\\textbackslash{}',
    '{': '\\{',
    '}': '\\}',
    '#': '\\#',
    '$': '\\$',
    '%': '\\%',
    '&': '\\&',
    '_': '\\_',
    '^': '\\textasciicircum{}',
    '~': '\\textasciitilde{}',
  };
  return Array.from(content.replace(/\r\n?/gu, '\n'), (char) => escapes[char] ?? char).join('');
}

function AssistantContentCard({
  content,
  busy,
  onCopy,
  onInsert,
}: {
  content: string;
  busy: boolean;
  onCopy: () => void;
  onInsert: () => void;
}) {
  const visible = visibleAssistantContent(content);
  return (
    <div className="overflow-hidden rounded-lg border bg-muted/30" data-testid="interactive-ai-assistant-card">
      <div className="flex items-center gap-2 border-b px-2 py-1.5">
        <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Assistant</span>
        <div className="ml-auto flex gap-1">
          <Button size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={onCopy}>
            <Copy className="mr-1 size-3.5" /> Copy
          </Button>
          <Button size="sm" className="h-7 px-2 text-xs" disabled={busy} onClick={onInsert}>
            <FileInput className="mr-1 size-3.5" /> Insert
          </Button>
        </div>
      </div>
      <div className="whitespace-pre-wrap break-words p-3 text-sm">{visible}</div>
    </div>
  );
}

function LatexSnippetCard({
  snippet,
  busy,
  onCopy,
  onInsert,
}: {
  snippet: ProjectAiLatexSnippet;
  busy: boolean;
  onCopy: () => void;
  onInsert: () => void;
}) {
  return (
    <div className="overflow-hidden rounded-lg border bg-muted/40" data-testid="interactive-ai-latex-snippet">
      <div className="flex items-center gap-2 border-b px-2 py-1.5">
        <span className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground">LaTeX · {snippet.category}</span>
        <div className="ml-auto flex gap-1">
          <Button size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={onCopy}>
            <Copy className="mr-1 size-3.5" /> Copy
          </Button>
          <Button size="sm" className="h-7 px-2 text-xs" disabled={busy} onClick={onInsert}>
            <FileInput className="mr-1 size-3.5" /> Insert
          </Button>
        </div>
      </div>
      <HighlightedLatex latex={snippet.latex} />
    </div>
  );
}

export function AiChatOverlay({
  wsName,
  onClose,
  manuscriptSelection,
  paperMemoryActionRequest,
  tabRequest,
  paperOpenRequest,
  agentRequest,
  assistantInputRequest,
  onAgentRequestConsumed,
  papersEnabled = true,
  latexAiActions = true,
}: {
  wsName: string;
  onClose: () => void;
  manuscriptSelection?: PaperMemoryManuscriptSelection;
  paperMemoryActionRequest?: PaperMemoryManuscriptActionRequest;
  tabRequest?: { id: number; tab: AiConversationTab };
  paperOpenRequest?: PaperMemoryOpenPaperRequest;
  agentRequest?: { id: number; text: string };
  assistantInputRequest?: { id: number; text: string };
  onAgentRequestConsumed?: (id: number) => void;
  papersEnabled?: boolean;
  latexAiActions?: boolean;
}) {
  const core = useCoreServices();
  const { projectAi, projectTools, latexSource, navigation, projectStorage, projectExecution, workspaceOps } = core;
  useAtomValue(projectAi.$version);
  const settings = useAtomValue(projectAi.$settings);
  const localAi = useAtomValue(projectAi.$localAi);
  const ollama = useAtomValue(projectAi.$ollama);
  const selectedOllamaModel = ollama.models.find((item) => item.model === settings.model || item.name === settings.model);
  const state = projectAi.getWorkspaceState(wsName);
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const [input, setInput] = React.useState('');
  const [activeTab, setActiveTab] = React.useState<AiConversationTab>('assistant');
  const [expandedDiff, setExpandedDiff] = React.useState<string>();
  const [assistantMode, setAssistantMode] = React.useState<'ask'|'agent'>('ask');
  const [agentActivity, setAgentActivity] = React.useState<string[]>([]);
  const [agentProgressDismissed, setAgentProgressDismissed] = React.useState(false);
  const [agentRunning, setAgentRunning] = React.useState(false);
  const handledAgentRequestRef = React.useRef<number>();
  const handledAssistantInputRequestRef = React.useRef<number>();
  const latestAgentCheckpointRef = React.useRef<Record<string, string>>();

  React.useEffect(() => {
    if (!assistantInputRequest || handledAssistantInputRequestRef.current === assistantInputRequest.id) return;
    handledAssistantInputRequestRef.current = assistantInputRequest.id;
    setActiveTab('assistant');
    setAssistantMode('ask');
    setInput(assistantInputRequest.text);
  }, [assistantInputRequest?.id]);

  React.useEffect(() => {
    if (!paperMemoryActionRequest || !papersEnabled) return;
    setActiveTab('papers');
  }, [paperMemoryActionRequest?.id]);

  React.useEffect(() => {
    if (!tabRequest) return;
    setActiveTab(tabRequest.tab === 'papers' && !papersEnabled ? 'assistant' : tabRequest.tab);
  }, [papersEnabled, tabRequest?.id]);

  React.useEffect(() => {
    if (!papersEnabled && activeTab === 'papers') setActiveTab('assistant');
  }, [activeTab, papersEnabled]);

  const localModelState = localAi.model?.modelId === settings.localModelId ? localAi.model : undefined;

  React.useEffect(() => {
    void projectAi.refreshLocalAiState(settings.localModelId).catch(() => undefined);
  }, [projectAi, settings.localModelId]);


  React.useEffect(() => {
    void state.messages.length;
    void state.proposal;
    void state.status;
    const node = scrollRef.current;
    if (node) node.scrollTop = node.scrollHeight;
  }, [state.messages.length, state.proposal, state.status, agentActivity.length]);

  const projectIdForWorkspace = async (): Promise<string | undefined> => {
    const workspace = await workspaceOps.getWorkspaceInfo(wsName).catch(() => undefined);
    return typeof workspace?.metadata?.projectId === 'string' ? workspace.metadata.projectId : undefined;
  };

  const captureCheckpoint = async () => {
    const provider=await projectStorage.getProvider(wsName); const entries=(await provider.listEntries()).filter((entry)=>entry.kind==='file');
    const snapshot:Record<string,string>={};
    for(const entry of entries){const bytes=await provider.readFile(entry.path);if(!bytes)continue;let binary='';for(let offset=0;offset<bytes.length;offset+=0x8000)binary+=String.fromCharCode(...bytes.subarray(offset,Math.min(bytes.length,offset+0x8000)));snapshot[String(entry.path)]=btoa(binary);}
    latestAgentCheckpointRef.current=snapshot;
  };

  const validationCommand = async (_projectId:string): Promise<{prepareCommand?:string;command:string;dev?:{command:string;port:number}}|undefined> => {
    const configured=undefined;
    const provider=await projectStorage.getProvider(wsName);
    const packageBytes=await provider.readFile(projectFilePath('package.json')).catch(()=>undefined);
    if(packageBytes){try{
      const pkg=JSON.parse(new TextDecoder().decode(packageBytes)) as {scripts?:Record<string,string>};
      const scripts=pkg.scripts||{};
      const hasPnpm=Boolean(await provider.readFile(projectFilePath('pnpm-lock.yaml')).catch(()=>undefined));
      const hasNpmLock=Boolean(await provider.readFile(projectFilePath('package-lock.json')).catch(()=>undefined));
      const hasYarn=Boolean(await provider.readFile(projectFilePath('yarn.lock')).catch(()=>undefined));
      const prepareCommand=hasPnpm?'test -d node_modules || pnpm install --frozen-lockfile':hasNpmLock?'test -d node_modules || npm ci --no-audit --no-fund':hasYarn?'test -d node_modules || yarn install --frozen-lockfile':'test -d node_modules || npm install --no-audit --no-fund';
      const command=configured?.buildCommand?.trim() || (scripts.build ? 'npm run build' : configured?.testCommand?.trim() || (scripts.test ? 'npm test -- --run' : `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))"`));
      const base=`/api/projects/${projectId}/processes/dev/preview/`;
      const startCommand=configured?.startCommand?.trim();
      const dev=startCommand?{command:startCommand,port:configured?.previewPort||5173}:scripts.dev?{command:`npm run dev -- --host 0.0.0.0 --base ${base}`,port:configured?.previewPort||5173}:undefined;
      return{prepareCommand,command,dev};
    }catch{/* continue */}}
    if(configured?.buildCommand?.trim())return{command:configured.buildCommand.trim(),dev:configured.startCommand?.trim()?{command:configured.startCommand.trim(),port:configured.previewPort||8000}:undefined};
    const entries=(await provider.listEntries()).filter((entry)=>entry.kind==='file').map(entry=>String(entry.path).toLowerCase());
    const staticIndex=entries.find(file=>file==='index.html'||file.endsWith('/index.html'));
    if(staticIndex){
      const port=configured?.previewPort||4173;
      return{
        command:`test -f ${JSON.stringify(staticIndex)}`,
        dev:{command:`python3 -m http.server ${port} --bind 0.0.0.0`,port},
      };
    }
    if(entries.includes('cargo.toml'))return{command:'cargo check'};
    if(entries.includes('go.mod'))return{command:'go test ./...'};

    // Canonical quick-code starters intentionally have no package manifest.
    // Verify them with the toolchains already installed in the development
    // sandbox so an Agent turn finishes with a real language-aware check.
    const profile=await projectExecution.resolveProfile(wsName).catch(()=>undefined);
    const entry=profile?.entry?.trim();
    const quotedEntry=entry?JSON.stringify(entry):undefined;
    if(profile?.projectType==='python'||(!profile&&entries.some(file=>file.endsWith('.py'))))return{command:'python3 -m compileall -q .',dev:configured?.startCommand?.trim()?{command:configured.startCommand.trim(),port:configured.previewPort||8000}:undefined};
    if(profile?.projectType==='javascript'&&quotedEntry)return{command:`node --check ${quotedEntry}`};
    if(profile?.projectType==='typescript'&&quotedEntry&&!/\.tsx$/iu.test(entry??''))return{command:`tsx --no-cache ${quotedEntry}`};
    if(profile?.projectType==='c'&&quotedEntry)return{command:`gcc -fsyntax-only ${quotedEntry}`};
    if(profile?.projectType==='cpp'&&quotedEntry)return{command:`g++ -fsyntax-only ${quotedEntry}`};
    if(profile?.projectType==='java'&&quotedEntry)return{command:`rm -rf /tmp/learnitive-agent-javac && mkdir -p /tmp/learnitive-agent-javac && javac -d /tmp/learnitive-agent-javac ${quotedEntry}`};
    if(profile?.projectType==='go'&&quotedEntry)return{command:`go build -o /tmp/learnitive-agent-go ${quotedEntry}`};
    if(profile?.projectType==='rust'&&quotedEntry)return{command:`rustc --edition=2021 --emit=metadata -o /tmp/learnitive-agent-rust.rmeta ${quotedEntry}`};
    if(profile?.projectType==='php'&&quotedEntry)return{command:`php -l ${quotedEntry}`};
    if(profile?.projectType==='ruby'&&quotedEntry)return{command:`ruby -c ${quotedEntry}`};
    if(profile?.projectType==='r'&&entry){const expression=`parse(file=${JSON.stringify(entry)})`;return{command:`Rscript -e ${JSON.stringify(expression)}`};}
    if(profile?.projectType==='shell'&&quotedEntry)return{command:`bash -n ${quotedEntry}`};
    return undefined;
  };

  const waitForPreview = async (projectId:string,processKey='dev'):Promise<{status:number;contentType:string|null}> => {
    let lastError:unknown;
    for(let attempt=0;attempt<20;attempt+=1){
      try{
        const response=await fetch(projectSandboxPreviewUrl(projectId,processKey),{credentials:'same-origin',cache:'no-store'});
        if(response.ok)return{status:response.status,contentType:response.headers.get('content-type')};
        lastError=new Error(`Preview returned HTTP ${response.status}.`);
      }catch(error){lastError=error;}
      await new Promise(resolve=>setTimeout(resolve,250));
    }
    throw lastError instanceof Error?lastError:new Error('Preview did not become ready.');
  };


  const testCommand = async (): Promise<string | undefined> => {
    const projectId=await projectIdForWorkspace();
    const configured=undefined;
    if(configured?.testCommand?.trim())return configured.testCommand.trim();
    const provider=await projectStorage.getProvider(wsName);
    const packageBytes=await provider.readFile(projectFilePath('package.json')).catch(()=>undefined);
    if(packageBytes){try{const pkg=JSON.parse(new TextDecoder().decode(packageBytes)) as {scripts?:Record<string,string>};if(pkg.scripts?.test)return 'npm test -- --run';}catch{/* continue */}}
    const entries=(await provider.listEntries()).filter((entry)=>entry.kind==='file').map(entry=>String(entry.path).toLowerCase());
    if(entries.includes('cargo.toml'))return 'cargo test';
    if(entries.includes('go.mod'))return 'go test ./...';
    if(entries.some(file=>file.endsWith('.py')))return 'python3 -m pytest -q';
    return undefined;
  };

  const compileToolExecutor = (): ProjectAiCompileToolExecutor => ({
    compileProject: async (requestedFile?: string) => {
      const explicitFile = requestedFile?.trim();
      const activePath = explicitFile
        ? String(projectFilePath(explicitFile))
        : projectAi.getActiveEditorPath(wsName) ? String(projectAi.getActiveEditorPath(wsName)) : undefined;
      let thrown: unknown;
      try {
        await core.projectCompileOrchestrator.compile(wsName, {
          ...(activePath ? { activePath } : {}),
          prepare: () => flushProjectEditorDrafts(wsName),
        });
      } catch (reason) {
        thrown = reason;
      }
      const compileState = core.projectCompileOrchestrator.getState();
      const result = compileState.result;
      const diagnostics = result?.diagnostics ?? (compileState.error
        ? [{ severity: 'error' as const, message: compileState.error, ...(activePath ? { file: activePath } : {}) }]
        : []);
      const status = compileState.phase === 'success'
        ? 'success'
        : compileState.phase === 'cancelled'
          ? 'cancelled'
          : 'error';
      return {
        status,
        file: activePath ?? compileState.activePath ?? compileState.profile?.entry ?? null,
        exitCode: result?.exitCode ?? (status === 'success' ? 0 : null),
        stdout: (result?.stdout ?? '').slice(-12_000),
        stderr: (result?.stderr ?? compileState.error ?? (thrown instanceof Error ? thrown.message : thrown ? String(thrown) : '')).slice(-12_000),
        diagnostics: diagnostics.slice(0, 50),
        executionReference: result?.executionId ?? compileState.executionId ?? null,
        provider: result?.provider ?? compileState.profile?.providers[compileState.operation ?? 'RUN'] ?? null,
        runtime: compileState.profile?.runtime ?? compileState.profile?.language ?? result?.provider ?? null,
        operation: compileState.operation ?? result?.operation ?? null,
        durationMs: compileState.duration ?? result?.duration ?? null,
      };
    },
  });

  const sandboxToolExecutor = async (projectId:string): Promise<ProjectAiSandboxToolExecutor> => {
    const withTool = async <T,>(_toolName:string, _inputSummary:string, operation:()=>Promise<T>, _summarize:(result:T)=>string=()=> 'completed'):Promise<T> => operation();
    return {
      runCommand: async (command,timeoutSeconds) => withTool('run_command',`command (${command.length} chars)`,async()=>{setAgentActivity(current=>[...current,`Running ${command}…`]);return runProjectSandboxCommand(projectId,command,timeoutSeconds);},result=>`exit=${result.exitCode}; stdout=${result.stdout?.length||0} chars; stderr=${result.stderr?.length||0} chars`),
      runBuild: async () => withTool('run_build','project build command',async()=>{const validation=await validationCommand(projectId);if(!validation)throw new Error('No project build/validation command is configured.');if(validation.prepareCommand){const prepared=await runProjectSandboxCommand(projectId,validation.prepareCommand,300);if(prepared.exitCode!==0)return prepared;}setAgentActivity(current=>[...current,`Building with ${validation.command}…`]);return runProjectSandboxCommand(projectId,validation.command,300);},result=>`exit=${result.exitCode}; stdout=${result.stdout?.length||0} chars; stderr=${result.stderr?.length||0} chars`),
      runTests: async () => withTool('run_tests','project test command',async()=>{const command=await testCommand();if(!command)throw new Error('No project test command is available.');setAgentActivity(current=>[...current,`Testing with ${command}…`]);return runProjectSandboxCommand(projectId,command,300);},result=>`exit=${result.exitCode}; stdout=${result.stdout?.length||0} chars; stderr=${result.stderr?.length||0} chars`),
      startProcess: async (input) => withTool('start_process',`process=${input.processKey||'auto'}; port=${input.port||'auto'}; command=${input.command.length} chars`,async()=>{setAgentActivity(current=>[...current,`Starting ${input.processKey||'process'}…`]);return startProjectSandboxProcess(projectId,input);},result=>`process=${result.processKey}; status=${result.status}; port=${result.port??'none'}`),
      stopProcess: async (processKey) => withTool('stop_process',`process=${processKey}`,async()=>{setAgentActivity(current=>[...current,`Stopping ${processKey}…`]);await stopProjectSandboxProcess(projectId,processKey);return{ok:true,processKey};},result=>`process=${result.processKey}; stopped=${result.ok}`),
      listProcesses: async () => withTool('inspect_processes','list project processes',()=>listProjectSandboxProcesses(projectId),result=>`${result.length} process(es)`),
      readProcessLogs: async (processKey) => withTool('inspect_runtime_logs',`process=${processKey}`,async()=>({processKey,logs:await getProjectSandboxProcessLogs(projectId,processKey)}),result=>`process=${result.processKey}; logs=${result.logs.length} chars`),
      inspectPreview: async (processKey='dev') => withTool('inspect_preview',`process=${processKey}`,async()=>{const response=await fetch(projectSandboxPreviewUrl(projectId,processKey),{credentials:'same-origin',cache:'no-store'});const text=(await response.text()).slice(0,4000);const diagnostics=recentPreviewDiagnostics(projectId,100);return{processKey,status:response.status,ok:response.ok,contentType:response.headers.get('content-type'),body:text,diagnostics};},result=>`process=${result.processKey}; status=${result.status}; ok=${result.ok}; body=${result.body.length} chars; diagnostics=${result.diagnostics.length}`),
    };
  };


  const runAgentTurn = async (text:string) => {
    if(agentRunning) throw new Error('An Agent task is already running.');
    setAgentRunning(true);
    setAgentProgressDismissed(false);
    let projectId:string|undefined;
    try {
      const readiness=projectAi.agentReadiness();
      if(!readiness.ready) throw new Error(readiness.reason??'Configure an Agent-capable AI provider before starting Agent.');
      projectId=await projectIdForWorkspace();
      if(!projectId) throw new Error('Agent requires a local Learnitive project identity.');
      setAgentActivity(['Planning and reading project…']);
      projectAi.setCompileToolExecutor(compileToolExecutor());
      projectAi.setSandboxToolExecutor(await sandboxToolExecutor(projectId));
      await projectAi.send(wsName,text);
      for(let repairRound=0;repairRound<2;repairRound+=1){
        const latest=projectAi.getWorkspaceState(wsName);
        if(!latest.proposal){
          setAgentActivity(current=>[...current,'Agent responded without project file changes.']);
          break;
        }
        // Only reconcile an unsaved editor draft after the Agent has returned a
        // concrete proposal. Starting an Agent task must never save user work
        // that may not ultimately produce file mutations.
        await flushProjectEditorDrafts(wsName);
        const changedFiles=[...new Set(latest.proposal.operations.flatMap((operation)=>operation.kind==='rename-file'?[String(operation.from),String(operation.to)]:[String(operation.file)]))];
        setAgentActivity(current=>[...current,`Prepared ${latest.proposal.operations.length} change${latest.proposal.operations.length===1?'':'s'} across ${changedFiles.length} file${changedFiles.length===1?'':'s'}${changedFiles.length?`: ${changedFiles.slice(0,4).join(', ')}${changedFiles.length>4?'…':''}`:''}`]);
        setAgentActivity(current=>[...current,'Creating local checkpoint…']); await captureCheckpoint();
        setAgentActivity(current=>[...current,'Applying project changes…']); await projectAi.applyProposal(wsName,{compile:false});
        setAgentActivity(current=>[...current,'Synchronizing changed source files to sandbox…']);
        const syncResult=await synchronizeSandboxProject(core,wsName,projectId);
        setAgentActivity(current=>[...current,`Sandbox synchronized: ${syncResult.uploaded} uploaded, ${syncResult.downloaded} downloaded${syncResult.conflicts.length?`, ${syncResult.conflicts.length} conflict${syncResult.conflicts.length===1?'':'s'}`:''}.`]);
        const validation=await validationCommand(projectId);
        if(!validation){
          setAgentActivity(current=>[...current,'Changes applied. Automatic verification is unavailable for this project type.']);
          break;
        }
        if(validation.prepareCommand){setAgentActivity(current=>[...current,'Restoring project dependencies…']);const prepared=await runProjectSandboxCommand(projectId,validation.prepareCommand,300);if(prepared.exitCode!==0){throw new Error(`Dependency restore failed:
${(prepared.stderr||prepared.stdout).slice(-4000)}`);}}
        setAgentActivity(current=>[...current,`Running ${validation.command}…`]); const result=await runProjectSandboxCommand(projectId,validation.command,300);
        if(result.exitCode===0){
          if(validation.dev){
            setAgentActivity(current=>[...current,'Verification passed. Starting or refreshing Preview…']);
            try{await stopProjectSandboxProcess(projectId,'dev');}catch{/* no existing process */}
            await startProjectSandboxProcess(projectId,{...validation.dev,processKey:'dev'});
            const preview=await waitForPreview(projectId,'dev');
            setAgentActivity(current=>[...current,'Verified. Preview is ready.']);
          }else setAgentActivity(current=>[...current,'Verified.']);
          const verifiedProfile=await projectExecution.resolveProfile(wsName).catch(()=>undefined);
          if(verifiedProfile&&await core.projectCompileOrchestrator.presentVerifiedWebOutput(wsName,verifiedProfile)){
            setAgentActivity(current=>[...current,'Refreshed Web Preview.']);
          }
          break;
        }
        if(repairRound>=1)throw new Error(`Agent verification failed:
${(result.stderr||result.stdout).slice(-4000)}`);
          setAgentActivity(current=>[...current,'Verification failed; asking AI for a bounded repair…']);
        await projectAi.send(wsName,`The sandbox verification command \`${validation.command}\` failed. Fix the project using the actual error below. Do not claim success until the next verification passes.\n\n${(result.stderr||result.stdout).slice(-6000)}`);
      }
      const finalState=projectAi.getWorkspaceState(wsName);
      const assistant=[...finalState.messages].reverse().find(message=>message.role==='assistant');
    } catch (reason) {
      const message=reason instanceof Error?reason.message:String(reason);
      setAgentActivity(current=>[...current,`Failed: ${message.split('\n')[0].slice(0,240)}`]);
      throw reason;
    } finally {
      projectAi.setCompileToolExecutor(undefined);
      projectAi.setSandboxToolExecutor(undefined);
      setAgentRunning(false);
    }
  };


  const runAskTurn = async (text:string) => {
    projectAi.setCompileToolExecutor(compileToolExecutor());
    try {
      await projectAi.send(wsName,text);
    } finally {
      projectAi.setCompileToolExecutor(undefined);
    }
  };

  const send = async () => {
    const text = input.trim();
    if (!text) return;
    setInput('');
    try {
      if(assistantMode==='agent') await runAgentTurn(text); else await runAskTurn(text);
    } catch (reason) {
      console.error('Learnitive AI request failed', reason);
      const message = reason instanceof Error ? reason.message : t.app.ai.requestFailed;
      if (assistantMode==='agent' || /Download the selected local Gemma|Enable Learnitive AI|Privacy Mode|Ollama/u.test(message)) openAiSettingsDialog();
      toast.error(message);
    }
  };

  React.useEffect(() => {
    if (!agentRequest || handledAgentRequestRef.current === agentRequest.id) return;
    handledAgentRequestRef.current = agentRequest.id;
    // Build-with-AI handoffs are one-shot requests owned by the editor page.
    // Consume the request as soon as this mounted overlay claims it so closing
    // and reopening the AI panel cannot replay the same prompt with a fresh
    // overlay-local handledAgentRequestRef.
    onAgentRequestConsumed?.(agentRequest.id);
    setAssistantMode('agent');
    setActiveTab('assistant');
    void runAgentTurn(agentRequest.text).catch((reason) => {
      console.error('Learnitive Build with AI failed', reason);
      if(!projectAi.agentReadiness().ready) openAiSettingsDialog();
      toast.error(reason instanceof Error ? reason.message : 'Build with AI failed.');
    });
  }, [agentRequest?.id, onAgentRequestConsumed]);

  const undoLatestAgentTurn = async () => {
    const snapshot=latestAgentCheckpointRef.current; if(!snapshot) { toast.info('No AI checkpoint is available in this session.'); return; }
    const provider=await projectStorage.getProvider(wsName); const current=(await provider.listEntries()).filter(entry=>entry.kind==='file');
    const keep=new Set(Object.keys(snapshot)); for(const entry of current)if(!keep.has(String(entry.path)))await core.fileSystem.deleteFile(WsPath.fromParts(wsName,entry.path).wsPath);
    for(const [path,value] of Object.entries(snapshot)){const binary=atob(value);const bytes=Uint8Array.from(binary,char=>char.charCodeAt(0));const wsPath=WsPath.fromParts(wsName,projectFilePath(path)).wsPath;const file=new File([bytes],WsPath.assertFile(wsPath).fileName);if(await core.fileSystem.exists(wsPath))await core.fileSystem.writeFile(wsPath,file);else await core.fileSystem.createFile(wsPath,file);}
    const projectId=await projectIdForWorkspace(); if(projectId) await synchronizeSandboxProject(core,wsName,projectId).catch(()=>undefined); latestAgentCheckpointRef.current=undefined; toast.success('Reverted the latest local AI checkpoint.');
  };

  const apply = async (compile: boolean) => {
    try {
      await projectAi.applyProposal(wsName, { compile });
      toast.success(compile ? t.app.ai.appliedAndCompiled : t.app.ai.applied);
    } catch (reason) {
      console.error('Unable to apply Learnitive AI proposal', reason);
      toast.error(reason instanceof Error ? reason.message : t.app.ai.applyFailed);
    }
  };

  const busy = agentRunning || state.status === 'thinking' || state.status === 'applying' || state.status === 'compiling';
  const latestEvidenceMessage = [...state.messages].reverse().find(
    (message) => message.role === 'assistant' && Boolean(message.sources?.length),
  );
  const assistantEvidence = latestEvidenceMessage?.sources?.length ? (
    <div className="space-y-2" role="group" aria-label="Assistant evidence">
      {latestEvidenceMessage.sources.map((source) => (
        <button
          key={`evidence:${latestEvidenceMessage.id}:${source.sourceId}`}
          type="button"
          className="block w-full rounded border bg-background px-2 py-2 text-left text-xs hover:bg-muted/40"
          onClick={() => {
            void projectTools.resolveProjectSourceAnchor(wsName, source.anchor)
              .then((location) => {
                const targetPath = WsPath.fromParts(wsName, location.file).wsPath;
                if (isProjectCodeTextPath(location.file)) {
                  requestProjectEditorReveal(targetPath, location.line, location.column);
                } else if (isLearnitiveEditableTextPath(location.file)) {
                  latexSource.requestSelect(targetPath, location.line, location.column);
                }
                navigation.goWsFile(targetPath);
              })
              .catch((reason) => toast.error(reason instanceof Error ? reason.message : 'This project source is outdated.'));
          }}
        >
          <span className="font-medium">[{source.sourceId}] {source.title ?? source.file}</span>
          <span className="ml-1 text-muted-foreground">{source.file}:{source.line}</span>
          <span className="mt-1 block line-clamp-3 text-muted-foreground">{source.excerpt}</span>
        </button>
      ))}
    </div>
  ) : undefined;

  return (
    <>
      <AiConversationShell
        activeTab={activeTab}
        onTabChange={setActiveTab}
        onClose={onClose}
        evidence={activeTab === 'assistant' ? assistantEvidence : undefined}
        papersEnabled={papersEnabled}
        headerActions={(
          <>
            {activeTab === 'assistant' ? <div className="flex rounded-md border bg-background p-0.5 text-[10px]" aria-label="AI mode"><button type="button" className={`rounded px-2 py-1 ${assistantMode==='ask'?'bg-muted font-medium':''}`} onClick={()=>setAssistantMode('ask')}>Ask</button><button type="button" className={`rounded px-2 py-1 ${assistantMode==='agent'?'bg-muted font-medium':''}`} onClick={()=>setAssistantMode('agent')}>Agent</button></div> : null}
            <Button
              size="icon"
              variant="ghost"
              aria-label={t.app.ai.settings}
              onClick={openAiSettingsDialog}
            >
              <Settings2 className="size-4" />
            </Button>
            <Button size="icon" variant="ghost" aria-label={t.app.ai.newChat} onClick={() => projectAi.newChat(wsName)}>
              <Trash2 className="size-4" />
            </Button>
          </>
        )}
      >
        {activeTab === 'papers' ? (
          <PaperMemoryErrorBoundary wsName={wsName}>
            <PaperMemoryPanel
              wsName={wsName}
              manuscriptSelection={manuscriptSelection}
              actionRequest={paperMemoryActionRequest}
              openPaperRequest={paperOpenRequest}
            />
          </PaperMemoryErrorBoundary>
        ) : (
          <>
            <div ref={scrollRef} className="min-h-32 flex-1 space-y-3 overflow-y-auto p-3" data-testid="interactive-ai-messages">
              {state.messages.length === 0 && !state.proposal && (
                <div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
                  <p className="font-medium text-foreground">{t.app.ai.welcome}</p>
                  <p className="mt-1">{settings.provider === 'local' ? 'Local Gemma keeps project prompts and generated responses in this browser. Project questions can use deterministic search plus local Meaning evidence with clickable sources. Downloading model/runtime assets uses the network.' : settings.provider === 'ollama' ? (selectedOllamaModel?.remote ? 'Ollama Cloud is reached through the Ollama service on this computer. Manuscript context may leave this computer through Ollama Cloud, so Privacy Mode blocks this model.' : 'Local Ollama runs inference on this computer. Keep Ollama running while you write; if it stops, Learnitive automatically falls back to your last configured BYOK AI cloud provider when Privacy Mode permits it.') : t.app.ai.contextHint}</p>
                  {settings.provider === 'local' && localModelState?.status === 'not-installed' && <Button className="mt-2" size="sm" variant="outline" onClick={openAiSettingsDialog}>Set up local model</Button>}
                </div>
              )}
              {state.messages.map((message) => (
                message.role === 'assistant' && message.snippet && latexAiActions ? (
                  <div key={message.id} className="mr-2 text-sm">
                    <LatexSnippetCard
                      snippet={message.snippet}
                      busy={busy}
                      onCopy={() => {
                        void navigator.clipboard.writeText(message.snippet!.latex)
                          .then(() => toast.success('LaTeX copied'))
                          .catch(() => toast.error('Unable to copy LaTeX'));
                      }}
                      onInsert={() => {
                        void projectAi.insertLatexSnippetAtCurrentEditor(wsName, message.snippet!.latex)
                          .then((location) => toast.success(`Inserted into ${location.file} at line ${location.line}`))
                          .catch((reason) => toast.error(reason instanceof Error ? reason.message : 'Unable to insert LaTeX'));
                      }}
                    />
                  </div>
                ) : (
                  <AiMessageBubble key={message.id} messageRole={message.role}>
                    {message.role === 'assistant' && latexAiActions ? (
                      <AssistantContentCard
                        content={message.content}
                        busy={busy}
                        onCopy={() => {
                          void navigator.clipboard.writeText(visibleAssistantContent(message.content))
                            .then(() => toast.success('Response copied'))
                            .catch(() => toast.error('Unable to copy response'));
                        }}
                        onInsert={() => {
                          const content = assistantProseToLatex(visibleAssistantContent(message.content));
                          void projectAi.insertLatexSnippetAtCurrentEditor(wsName, content)
                            .then((location) => toast.success(`Inserted into ${location.file} at line ${location.line}`))
                            .catch((reason) => toast.error(reason instanceof Error ? reason.message : 'Unable to insert response'));
                        }}
                      />
                    ) : (
                      <div className="whitespace-pre-wrap break-words">{message.content}</div>
                    )}
                    {message.role === 'assistant' && message.sources?.length ? (
                      <div className="mt-2 space-y-1 border-t border-border/60 pt-2" role="group" aria-label="Project evidence">
                        <div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Project evidence</div>
                        {message.sources.map((source) => (
                          <button
                            key={`${message.id}:${source.sourceId}`}
                            type="button"
                            className="block w-full rounded border bg-background/70 px-2 py-1.5 text-left text-xs text-foreground hover:bg-background"
                            onClick={() => {
                              void projectTools.resolveProjectSourceAnchor(wsName, source.anchor)
                                .then((location) => {
                                  const targetPath = WsPath.fromParts(wsName, location.file).wsPath;
                                  if (isProjectCodeTextPath(location.file)) {
                                    requestProjectEditorReveal(targetPath, location.line, location.column);
                                  } else if (isLearnitiveEditableTextPath(location.file)) {
                                    latexSource.requestSelect(targetPath, location.line, location.column);
                                  }
                                  navigation.goWsFile(targetPath);
                                })
                                .catch((reason) => toast.error(reason instanceof Error ? reason.message : 'This project source is outdated.'));
                            }}
                          >
                            <span className="font-medium">[{source.sourceId}] {source.title ?? source.file}</span>
                            <span className="ml-1 text-muted-foreground">{source.file}:{source.line}</span>
                            <span className="mt-0.5 block line-clamp-2 text-muted-foreground">{source.excerpt}</span>
                          </button>
                        ))}
                      </div>
                    ) : null}
                  </AiMessageBubble>
                )
              ))}

              {assistantMode === 'agent' && agentActivity.length > 0 && !agentProgressDismissed ? (
                <div className="rounded-lg border bg-background p-3 text-xs" aria-label="Agent progress" data-testid="learnitive-agent-activity">
                  <div className="mb-2 flex items-center justify-between gap-2">
                    <span className="font-medium text-foreground">Agent progress</span>
                    <Button size="icon" variant="ghost" className="size-7" aria-label="Close Agent progress" title="Close Agent progress" onClick={()=>setAgentProgressDismissed(true)}>
                      <X className="size-3.5" />
                    </Button>
                  </div>
                  <div className="space-y-1.5">
                    {agentActivity.slice(-12).map((item,index,array)=>{
                      const failed=item.startsWith('Failed:');
                      const active=agentRunning && index===array.length-1;
                      return (
                        <div key={`${index}:${item}`} className="flex items-start gap-2 text-muted-foreground">
                          {failed ? (
                            <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">×</span>
                          ) : active ? (
                            <span className="mt-1 size-3.5 shrink-0 rounded-full border-2 border-amber-400 bg-amber-100 dark:bg-amber-950" aria-hidden="true" />
                          ) : (
                            <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-amber-400 text-amber-950"><Check className="size-3" strokeWidth={3} /></span>
                          )}
                          <span className={failed?'text-destructive':active?'font-medium text-foreground':''}>{item}</span>
                        </div>
                      );
                    })}
                  </div>
                  {!agentRunning ? (
                    <div className="mt-2 flex justify-end border-t pt-2">
                      <Button size="sm" variant="ghost" className="h-7 px-2 text-[10px]" disabled={busy} onClick={()=>void undoLatestAgentTurn()}>Undo AI turn</Button>
                    </div>
                  ) : null}
                </div>
              ) : null}

              {state.proposal && (
                <div className="space-y-2 rounded-lg border border-amber-300 bg-amber-50/70 p-3 text-xs dark:border-amber-900 dark:bg-amber-950/20" data-testid="interactive-ai-proposal">
                  <div className="flex items-center gap-2 font-medium"><Check className="size-4" />{state.proposal.title}</div>
                  {state.proposal.operations.map((operation) => {
                    const operationKey = proposalOperationKey(operation);
                    return <ProposalOperation key={operationKey} operation={operation} expanded={expandedDiff === operationKey} onToggle={() => setExpandedDiff((value) => value === operationKey ? undefined : operationKey)} />;
                  })}
                  <div className="flex flex-wrap justify-end gap-2 pt-1">
                    <Button size="sm" variant="ghost" onClick={() => projectAi.rejectProposal(wsName)}>{t.app.ai.reject}</Button>
                    <Button size="sm" variant="outline" onClick={() => void apply(false)}>{t.app.ai.apply}</Button>
                    <Button size="sm" onClick={() => void apply(true)}>{t.app.ai.applyCompile}</Button>
                  </div>
                </div>
              )}

              {state.error && <div className="rounded-md border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">{state.error}</div>}
              {busy && !agentRunning ? <div className="text-xs text-muted-foreground">{state.status === 'compiling' ? t.app.ai.compiling : state.status === 'applying' ? t.app.ai.applying : t.app.ai.thinking}</div> : null}
            </div>

            <div className="border-t p-2">
              <AiComposer
                value={input}
                onChange={setInput}
                onSend={() => void send()}
                onStop={() => projectAi.cancel(wsName)}
                busy={state.status === 'thinking' || agentRunning}
                disabled={busy || !settings.enabled}
                ariaLabel={t.app.ai.inputPlaceholder}
                placeholder={t.app.ai.inputPlaceholder}
              />
              <p className="mt-1 px-1 text-[10px] text-muted-foreground">
                {settings.provider === 'auto' ? 'Auto mode: ordinary text prefers browser-local AI; Agent/vision may use your configured cloud profile when Privacy Mode permits it.' : settings.provider === 'local' ? 'Local mode: prompts, project context and responses remain in this browser.' : settings.provider === 'ollama' ? 'Ollama mode: prompts and project context are sent only to the selected Ollama model. If it stops, Learnitive reports the failure and requires you to retry or switch models explicitly.' : settings.provider === 'disabled' ? 'AI is disabled.' : t.app.ai.byokNotice}
              </p>
            </div>
          </>
        )}
      </AiConversationShell>
    </>
  );
}

function proposalOperationKey(operation: ProjectAiProposalOperation): string {
  if (operation.kind === 'rename-file') return `${operation.kind}:${operation.from}:${operation.to}`;
  return `${operation.kind}:${operation.file}`;
}

function keyedProposalDiffLines(operation: Extract<ProjectAiProposalOperation, { kind: 'replace-file' }>) {
  const occurrences = new Map<string, number>();
  return operation.diff
    .filter((line) => line.kind !== 'context')
    .slice(0, 240)
    .map((line) => {
      const base = `${line.kind}:${line.text}`;
      const occurrence = (occurrences.get(base) ?? 0) + 1;
      occurrences.set(base, occurrence);
      return { line, key: `${base}:${occurrence}` };
    });
}

function ProposalOperation({
  operation,
  expanded,
  onToggle,
}: {
  operation: ProjectAiProposalOperation;
  expanded: boolean;
  onToggle: () => void;
}) {
  if (operation.kind === 'rename-file') return <div className="rounded border bg-background/80 px-2 py-1 font-mono">{operation.from} → {operation.to}</div>;
  if (operation.kind === 'create-file') {
    return (
      <div className="rounded border bg-background/80">
        <button type="button" className="flex w-full items-center gap-2 px-2 py-1 text-left font-mono" onClick={onToggle}>
          + {operation.file}{expanded ? <ChevronUp className="ml-auto size-3" /> : <ChevronDown className="ml-auto size-3" />}
        </button>
        {expanded && <pre className="max-h-48 overflow-auto border-t p-2 text-[10px] whitespace-pre-wrap">{operation.content}</pre>}
      </div>
    );
  }
  return (
    <div className="rounded border bg-background/80">
      <button type="button" className="flex w-full items-center gap-2 px-2 py-1 text-left font-mono" onClick={onToggle}>
        ± {operation.file}{expanded ? <ChevronUp className="ml-auto size-3" /> : <ChevronDown className="ml-auto size-3" />}
      </button>
      {expanded && (
        <pre className="max-h-52 overflow-auto border-t p-2 text-[10px]">
          {keyedProposalDiffLines(operation).map(({ line, key }) => (
            <div key={key} className={line.kind === 'add' ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-700 dark:text-red-400'}>
              {line.kind === 'add' ? '+' : '-'} {line.text}
            </div>
          ))}
        </pre>
      )}
    </div>
  );
}
