import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { logger } from '../logger.js';
import { resolveJobScheduling } from '../scheduling.js';
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
import { jobEventBus, type JobStreamEvent } from './job-events.js';

export interface LocalTasksApiOptions {
  repo: Repository;
  worktreeDir?: string;
  generateTitle?: (body: string) => Promise<string>;
  selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
  /**
   * Server-side validator for piece names accepted by the
   * /continue endpoint. Returns true if the piece is loadable.
   * When unset, /continue rejects all requests with 500 (misconfiguration).
   *
   * `ownerId` is the task owner's user id; when provided the implementation
   * MUST also check the owner's per-user piece dir so that user-custom pieces
   * are accepted (matching the resolution order the worker uses at run time).
   */
  pieceExists?: (name: string, ownerId?: string) => boolean;
  /**
   * Optional. When set, accepting browserSessionProfileId on task create
   * verifies the profile belongs to the requesting user. Without it, the
   * field is silently dropped (legacy / no-auth deployments).
   */
  sessRepo?: BrowserSessionRepo;
  /**
   * Optional. Returns the current upload size limit (MB) for task creation
   * and comment posting. Called per request so config changes take effect
   * without a server restart. Clamped to [1, 1000] MB. Default: 50.
   */
  getMaxUploadMb?: () => number;
}

export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
  const { repo, worktreeDir, sessRepo } = opts;

  const resolveUploadLimit = (): string => {
    const raw = opts.getMaxUploadMb?.() ?? 50;
    const mb = Number.isFinite(raw) ? Math.max(1, Math.min(1000, Math.floor(raw))) : 50;
    return `${mb}mb`;
  };
  const dynamicJson = () => (req: Request, res: Response, next: express.NextFunction) =>
    express.json({ limit: resolveUploadLimit() })(req, res, next);

  app.get('/api/local/tasks', async (req: Request, res: Response) => {
    try {
      const viewer = req.user as Express.User | undefined;
      const tasks = await repo.listLocalTasks(viewer ? { viewer } : {});
      res.json({ tasks });
    } catch (err) {
      logger.error(`Local tasks list API error: ${err}`);
      res.status(500).json({ error: 'Failed to fetch local tasks' });
    }
  });

  app.post('/api/local/tasks', dynamicJson(), async (req: Request, res: Response) => {
    try {
      const validation = validateCreateTaskBody(req.body);
      if (!validation.valid) {
        res.status(400).json({ error: validation.error });
        return;
      }
      const body = validation.data;

      // Visibility extraction + validation
      const rawVisibility = req.body?.visibility ?? 'private';
      if (!['private', 'org', 'public'].includes(rawVisibility)) {
        res.status(400).json({ error: 'invalid visibility' });
        return;
      }
      const visibility = rawVisibility as 'private' | 'org' | 'public';
      const rawScopeOrgId = req.body?.visibilityScopeOrgId;
      const visibilityScopeOrgId: string | null =
        typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
      if (visibility === 'org') {
        const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
        if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
          res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
          return;
        }
      }

      // Optional browser session profile binding. Owner-scoped check
      // (sessRepo.getProfileById enforces owner_id = req.user.id) prevents
      // user A from binding user B's profile to their task.
      let browserSessionProfileId: number | null = null;
      const rawProfileId = req.body?.browserSessionProfileId;
      if (rawProfileId !== undefined && rawProfileId !== null && rawProfileId !== '') {
        const n = Number(rawProfileId);
        if (!Number.isInteger(n) || n <= 0) {
          res.status(400).json({ error: 'browserSessionProfileId must be a positive integer' });
          return;
        }
        if (sessRepo) {
          const userId = (req.user as Express.User | undefined)?.id;
          if (!userId) {
            res.status(400).json({ error: 'browserSessionProfileId requires an authenticated user' });
            return;
          }
          const owned = sessRepo.getProfileById(n, userId);
          if (!owned) {
            res.status(400).json({ error: 'browser session profile not found or not owned by you' });
            return;
          }
        }
        browserSessionProfileId = n;
      }

      let taskTitle = (body.title ?? '').trim();
      const rawPiece = (body.piece ?? 'auto').trim();
      const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];

      // タイトル生成と piece 分類を並列実行
      const [generatedTitle, autoSelectedPiece] = await Promise.all([
        // タイトル生成
        (!taskTitle && opts.generateTitle)
          ? Promise.race([
              opts.generateTitle(body.body.trim()),
              new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
            ]).catch((e: unknown) => { logger.warn(`Title generation failed: ${e}`); return ''; })
          : Promise.resolve(''),
        // piece 分類（'auto' の場合のみ）; userId を渡し per-user カタログを使用
        (rawPiece === 'auto' && opts.selectPiece)
          ? opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id).catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
          : Promise.resolve(rawPiece),
      ]);

      if (!taskTitle) {
        taskTitle = generatedTitle || body.body.trim().slice(0, 40).replace(/\n/g, ' ');
      }
      const piece = autoSelectedPiece;
      const profile = body.profile ?? 'auto';
      const outputFormat = body.outputFormat ?? 'markdown';
      const askPolicy = body.askPolicy ?? 'low';
      const priority = body.priority ?? 'medium';
      const scheduling = resolveJobScheduling({
        role: profile,
        pieceName: piece,
        instruction: body.body.trim(),
      });

      // Per-task options (e.g. { mcpDisabled, skillsDisabled })
      const rawOptions = req.body?.options;
      const taskOptions: Record<string, unknown> =
        rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)
          ? rawOptions as Record<string, unknown>
          : {};

      const task = await repo.createLocalTask({
        title: taskTitle,
        body: body.body.trim(),
        pieceName: piece,
        profile,
        outputFormat,
        askPolicy,
        priority,
        ownerId: req.user?.id,
        visibility,
        visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
        browserSessionProfileId,
        options: taskOptions,
      });

      const workspacePath = getLocalWorkspacePath(worktreeDir, task.id);
      mkdirSync(join(workspacePath, 'input'), { recursive: true });
      mkdirSync(join(workspacePath, 'output'), { recursive: true });
      mkdirSync(join(workspacePath, 'logs'), { recursive: true });
      await repo.updateLocalTask(task.id, { workspacePath });

      for (const att of body.attachments ?? []) {
        if (!att.name || !att.contentBase64) continue;
        const safeName = att.name.replace(/[\\/]/g, '_');
        writeFileSync(join(workspacePath, 'input', safeName), Buffer.from(att.contentBase64, 'base64'));
      }

      await repo.addLocalTaskComment(task.id, 'user', body.body.trim(), 'request');

      const metadataBlock = [
        '---',
        `ui_profile: ${scheduling.role}`,
        `ui_output_format: ${outputFormat}`,
        `ui_ask_policy: ${askPolicy}`,
        `ui_priority: ${priority}`,
        '---',
      ].join('\n');
      const instruction = `${taskTitle}\n\n${body.body.trim()}\n\n${metadataBlock}`.trim();
      // Merge task options into job payload so the worker can read them at runtime.
      const hasOptions = Object.keys(taskOptions).length > 0;
      const job = await repo.createJob({
        repo: localTaskRepoName(task.id),
        issueNumber: task.id,
        instruction,
        pieceName: piece,
        role: scheduling.role,
        ownerId: task.ownerId,
        visibility: task.visibility,
        visibilityScopeOrgId: task.visibilityScopeOrgId,
        browserSessionProfileId: task.browserSessionProfileId ?? null,
        payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
      });
      await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });

      if (rawPiece === 'auto') {
        await repo.addAuditLog(job.id, 'piece_auto_selected', 'piece-classifier', {
          selectedPiece: piece,
        });
      }

      const created = await repo.getLocalTask(task.id);
      res.status(201).json({ task: created, jobId: job.id });
    } catch (err) {
      logger.error(`Create local task API error: ${err}`);
      res.status(500).json({ error: 'Failed to create local task' });
    }
  });

  app.get('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!canViewTask(req, res, task)) return;
      res.json({ task });
    } catch (err) {
      logger.error(`Local task detail API error: ${err}`);
      res.status(500).json({ error: 'Failed to fetch local task' });
    }
  });

  app.put('/api/local/tasks/:taskId/feedback', express.json(), async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const validation = validateFeedbackBody(req.body);
      if (!validation.valid) {
        res.status(400).json({ error: validation.error });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!checkTaskOwnership(req, res, task)) return;
      await repo.updateFeedback(taskId, validation.data);
      const updated = await repo.getLocalTask(taskId);
      res.json({ task: updated });
    } catch (err) {
      logger.error(`Local task feedback API error: ${err}`);
      res.status(500).json({ error: 'Failed to update feedback' });
    }
  });

  app.put('/api/local/tasks/:taskId/mission', express.json(), async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!checkTaskOwnership(req, res, task)) return;

      // Partial-replace: only string fields are written. Anything else
      // (null, undefined, non-string) is treated as "leave unchanged".
      // To clear a field, send an empty string.
      const body = (req.body ?? {}) as Record<string, unknown>;
      const patch: Record<string, string> = {};
      for (const key of ['goal', 'done', 'open', 'clarifications'] as const) {
        const v = body[key];
        if (typeof v === 'string') patch[key] = v;
      }
      if (Object.keys(patch).length === 0) {
        res.status(400).json({ error: 'No mission fields provided. Send goal, done, open, or clarifications as strings.' });
        return;
      }
      const merged = await repo.updateMissionBrief(taskId, patch);
      res.json({ missionBrief: merged });
    } catch (err) {
      logger.error(`Local task mission API error: ${err}`);
      res.status(500).json({ error: 'Failed to update mission brief' });
    }
  });

  app.get('/api/local/tasks/:taskId/comments', async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!canViewTask(req, res, task)) return;
      const comments = await repo.listLocalTaskComments(taskId);
      res.json({ comments });
    } catch (err) {
      logger.error(`Local task comments API error: ${err}`);
      res.status(500).json({ error: 'Failed to fetch local task comments' });
    }
  });

  app.post('/api/local/tasks/:taskId/comments', dynamicJson(), async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const commentValidation = validateCommentBody(req.body);
      if (!commentValidation.valid) {
        res.status(400).json({ error: commentValidation.error });
        return;
      }
      const { body, author, attachments } = commentValidation;
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!checkTaskOwnership(req, res, task)) return;

      // Save attachments to input/
      if (attachments && attachments.length > 0 && task?.workspacePath) {
        const inputDir = join(task.workspacePath, 'input');
        mkdirSync(inputDir, { recursive: true });
        for (const att of attachments) {
          if (!att.name || !att.contentBase64) continue;
          const safeName = att.name.replace(/[\\/]/g, '_');
          writeFileSync(join(inputDir, safeName), Buffer.from(att.contentBase64, 'base64'));
        }
      }

      const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);

      // running / dispatching / waiting_subtasks 中: コメント保存のみ（agent-loop が注入する）
      const isActive = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
      const commentKind = isActive ? 'interjection' : 'comment';
      const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind);

      if (isActive) {
        logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
        res.status(201).json({ comment, jobId: prevJob!.id, interjection: true });
        return;
      }

      const askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0;
      const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;

      // Build instruction with attachment info
      const savedFileNames = (attachments ?? [])
        .filter(att => att.name && att.contentBase64)
        .map(att => att.name.replace(/[\\/]/g, '_'));
      const instruction = savedFileNames.length > 0
        ? `${body}\n\n添付ファイル（input/ に保存済み）: ${savedFileNames.join(', ')}`
        : body;

      const job = await repo.createJob({
        repo: localTaskRepoName(taskId),
        issueNumber: taskId,
        instruction,
        pieceName: task!.pieceName,
        askCount,
        resumeMovement,
        role: prevJob?.requiredRole,
        ownerId: task!.ownerId,
        visibility: task!.visibility,
        visibilityScopeOrgId: task!.visibilityScopeOrgId,
        browserSessionProfileId: task!.browserSessionProfileId ?? null,
      });
      await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });

      res.status(201).json({ comment, jobId: job.id });
    } catch (err) {
      logger.error(`Local task comment create API error: ${err}`);
      res.status(500).json({ error: 'Failed to post local task comment' });
    }
  });

  app.patch('/api/local/tasks/:taskId', express.json(), async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
      const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
      if (!checkTaskOwnership(req, res, task)) return;

      const updates: { visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
      if (req.body.visibility !== undefined) {
        const v = req.body.visibility;
        if (!['private', 'org', 'public'].includes(v)) {
          res.status(400).json({ error: 'invalid visibility' }); return;
        }
        updates.visibility = v;
      }
      if (req.body.visibilityScopeOrgId !== undefined) {
        updates.visibilityScopeOrgId = req.body.visibilityScopeOrgId ?? null;
      }
      if (updates.visibility === 'org') {
        const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
        const scopeId = updates.visibilityScopeOrgId ?? task!.visibilityScopeOrgId ?? null;
        if (!scopeId || !orgIds.includes(scopeId)) {
          res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' }); return;
        }
        updates.visibilityScopeOrgId = scopeId;
      }
      if (updates.visibility && updates.visibility !== 'org') {
        updates.visibilityScopeOrgId = null;
      }
      await repo.updateLocalTask(taskId, updates);
      const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
      if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
        await repo.updateJobsVisibilityForTask(taskId, {
          visibility: refreshed.visibility ?? 'private',
          visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null,
        });
      }
      res.json({ task: refreshed });
    } catch (err) {
      logger.error(`Patch local task API error: ${err}`);
      res.status(500).json({ error: 'Failed to update task' });
    }
  });

  app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
      if (!checkTaskOwnership(req, res, task)) return;
      await repo.deleteLocalTask(taskId);
      res.json({ ok: true });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      if (message.includes('has an active job')) {
        res.status(409).json({ error: 'Cannot delete task with running jobs' });
        return;
      }
      logger.error(`Delete local task API error: ${err}`);
      res.status(500).json({ error: 'Failed to delete local task' });
    }
  });

  app.post('/api/local/tasks/:taskId/cancel', async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!checkTaskOwnership(req, res, task)) return;
      const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
      if (!latestJob || !['running', 'dispatching'].includes(latestJob.status)) {
        res.status(404).json({ error: 'No running job found' });
        return;
      }
      const cancelled = repo.requestJobCancel(latestJob.id);
      if (!cancelled) {
        res.status(409).json({ error: 'Job is no longer running' });
        return;
      }
      await repo.addAuditLog(latestJob.id, 'job_cancel_requested', 'local-ui', { taskId });
      logger.info(`Cancel requested for job ${latestJob.id} (task ${taskId})`);
      res.json({ ok: true, jobId: latestJob.id });
    } catch (err) {
      logger.error(`Cancel local task API error: ${err}`);
      res.status(500).json({ error: 'Failed to cancel task' });
    }
  });

  app.post('/api/local/tasks/:taskId/continue', express.json(), async (req: Request, res: Response) => {
    try {
      const taskId = parseTaskId(req.params.taskId);
      if (taskId === null) {
        res.status(400).json({ error: 'Invalid task ID' });
        return;
      }

      const piece = typeof req.body?.piece === 'string' ? req.body.piece.trim() : '';
      const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
      if (!piece) {
        res.status(400).json({ error: 'piece_required' });
        return;
      }
      if (!instruction.trim()) {
        res.status(400).json({ error: 'instruction_required' });
        return;
      }

      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
      if (!checkTaskOwnership(req, res, task)) return;

      // Piece existence check (server-side; UI dropdown is best-effort).
      if (!opts.pieceExists) {
        logger.error('[local-tasks-api] /continue invoked but pieceExists option not configured');
        res.status(500).json({ error: 'piece_validation_unavailable' });
        return;
      }
      if (!opts.pieceExists(piece, task?.ownerId ?? undefined)) {
        res.status(400).json({ error: 'piece_not_found', piece });
        return;
      }

      const prevJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
      if (!prevJob) {
        res.status(409).json({ error: 'no_previous_job' });
        return;
      }
      // jobs.status CHECK には 'aborted' が無い (worker が abort 結果を 'failed' に集約するため)。
      // 'waiting_subtasks' は子 job 待機の中間状態で、そこから別 piece に切り替えると孤立するので除外。
      const TERMINAL: ReadonlyArray<string> = ['succeeded', 'failed', 'waiting_human', 'cancelled'];
      if (!TERMINAL.includes(prevJob.status)) {
        res.status(409).json({ error: 'job_in_progress', currentStatus: prevJob.status });
        return;
      }

      const job = await repo.createJob({
        repo: localTaskRepoName(taskId),
        issueNumber: taskId,
        instruction: instruction.trim(),
        pieceName: piece,
        continuedFromJobId: prevJob.id,
        ownerId: task!.ownerId,
        role: prevJob.requiredRole,
        visibility: task!.visibility,
        visibilityScopeOrgId: task!.visibilityScopeOrgId,
        browserSessionProfileId: task!.browserSessionProfileId ?? null,
      });

      await repo.updateLocalTask(taskId, { pieceName: piece });

      // Surface the handoff in the timeline so the user (and the LLM, when
      // it later inspects task comments) can see when piece switches happened.
      await repo.addLocalTaskComment(
        taskId,
        'system',
        `🔄 Continued: piece="${prevJob.pieceName}" → piece="${piece}"`,
        'handoff',
      );

      await repo.addAuditLog(job.id, 'job_queued_local_continue', 'local-ui', {
        taskId,
        fromPiece: prevJob.pieceName,
        toPiece: piece,
        prevJobId: prevJob.id,
      });

      res.status(201).json({ jobId: job.id });
    } catch (err) {
      logger.error(`Local task continue API error: ${err}`);
      res.status(500).json({ error: 'Failed to continue task' });
    }
  });

  // ── SSE stream: real-time job events ──────────────────────────────────────
  app.get('/api/local/tasks/:taskId/stream', async (req: Request, res: Response) => {
    const taskId = parseTaskId(req.params.taskId);
    if (taskId === null) { res.status(400).json({ error: 'invalid taskId' }); return; }

    try {
      const viewer = req.user as Express.User | undefined;
      const task = await repo.getLocalTask(taskId, viewer ? { viewer } : {});
      if (!task) { res.status(404).json({ error: 'task not found' }); return; }

      const runningJob = task.latestJob;
      if (!runningJob || (runningJob.status !== 'running' && runningJob.status !== 'dispatching')) {
        res.status(204).end();
        return;
      }
      const jobId = runningJob.id;

      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-store');
      res.setHeader('Connection', 'keep-alive');
      res.setHeader('X-Accel-Buffering', 'no');
      res.flushHeaders();

      // Text delta batching (50ms flush)
      let textBuf = '';
      let flushTimer: ReturnType<typeof setTimeout> | null = null;
      const TEXT_FLUSH_MS = 50;

      // Tool-call argument delta batching, keyed by callId (50ms flush).
      const toolBuf = new Map<string, { name: string; chunk: string }>();
      let toolFlushTimer: ReturnType<typeof setTimeout> | null = null;

      const flushText = () => {
        if (textBuf) {
          const data = JSON.stringify({ type: 'text_delta', text: textBuf });
          res.write(`data: ${data}\n\n`);
          textBuf = '';
        }
        flushTimer = null;
      };

      const flushToolDeltas = () => {
        for (const [callId, { name, chunk }] of toolBuf) {
          if (res.writableEnded) break;
          res.write(`data: ${JSON.stringify({ type: 'tool_use_delta', callId, name, chunk })}\n\n`);
        }
        toolBuf.clear();
        toolFlushTimer = null;
      };

      const handler = (event: JobStreamEvent) => {
        if (res.writableEnded) return;
        if (event.type === 'text') {
          textBuf += event.text ?? '';
          if (!flushTimer) flushTimer = setTimeout(flushText, TEXT_FLUSH_MS);
          return;
        }
        if (event.type === 'tool_use_delta') {
          const callId = event.callId ?? '';
          // chunk is a full snapshot of args-so-far; keep the LATEST per
          // callId (replace, not append) so each flush sends the newest
          // complete prefix. Coalesces many snapshots into one per 50ms.
          toolBuf.set(callId, {
            name: event.name ?? toolBuf.get(callId)?.name ?? '',
            chunk: event.chunk ?? '',
          });
          if (!toolFlushTimer) toolFlushTimer = setTimeout(flushToolDeltas, TEXT_FLUSH_MS);
          return;
        }
        // Flush pending text + tool deltas before non-streaming events
        if (textBuf) flushText();
        if (toolBuf.size) flushToolDeltas();
        if (event.type === 'prompt_progress') {
          const effective = (event.processed ?? 0) - (event.cache ?? 0);
          const effectiveTotal = (event.total ?? 0) - (event.cache ?? 0);
          const percent = effectiveTotal > 0 ? Math.round(effective / effectiveTotal * 100) : 0;
          res.write(`data: ${JSON.stringify({ type: 'prompt_progress', percent, processed: event.processed, total: event.total, cache: event.cache, timeMs: event.timeMs })}\n\n`);
        } else if (event.type === 'done') {
          res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
          cleanup();
          res.end();
        } else {
          res.write(`data: ${JSON.stringify(event)}\n\n`);
        }
      };

      // Heartbeat to keep connection alive
      const heartbeat = setInterval(() => {
        if (!res.writableEnded) res.write(': heartbeat\n\n');
      }, 15_000);

      const cleanup = () => {
        jobEventBus.offJob(jobId, handler);
        clearInterval(heartbeat);
        if (flushTimer) { clearTimeout(flushTimer); flushText(); }
        if (toolFlushTimer) { clearTimeout(toolFlushTimer); flushToolDeltas(); }
      };

      jobEventBus.onJob(jobId, handler);
      req.on('close', cleanup);
    } catch (err) {
      logger.error(`Local task stream API error: ${err}`);
      if (!res.headersSent) res.status(500).json({ error: 'stream failed' });
    }
  });
}
