import express, { type Application, type Request, type Response } from 'express';
import { localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { checkTaskOwnership } from './local-api-helpers.js';
import { buildTitleFallback } from '../title-generation.js';
import { type LocalTasksDeps, makeDynamicJson, resolveTaskWriteGate } from './local-tasks-shared.js';

/**
 * Job control + on-demand helpers: cancel / continue (piece switch) /
 * regenerate-title / evaluate-prompt (prompt coach).
 */
export function registerLocalTaskControlRoutes(app: Application, deps: LocalTasksDeps): void {
  const { repo } = deps;
  const opts = deps.opts;
  const dynamicJson = makeDynamicJson(opts.getMaxUploadMb);

  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);
      // コメントと同じく、共有ワークスペースの editor 以上のメンバーは
      // 暴走ジョブを止められるべき。owner/admin/editor+ → 可、viewer → 403、
      // 非メンバー → 404。
      const cancelGate = await resolveTaskWriteGate(repo, viewer, task);
      if (cancelGate === 'deny') {
        res.status(404).json({ error: 'Task not found' });
        return;
      }
      if (cancelGate === 'view-only') {
        res.status(403).json({ error: '閲覧のみのため操作できません' });
        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,
        spaceId: task!.spaceId ?? null,
      });

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

      // Persist the switch-time instruction as a user request. Without this it
      // lives only in job.instruction, and buildLocalConversationContext picks
      // the *latest user comment* as the current instruction — so a stale older
      // comment would win and the switch text would be demoted to the "original
      // task (possibly already handled)" slot, making the agent re-follow prior
      // instructions instead of the new one. Mirrors the create path, which
      // also persists the body as a 'request' comment.
      await repo.addLocalTaskComment(taskId, 'user', instruction.trim(), 'request');

      // 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' });
    }
  });

  // On-demand AI title regeneration. Unlike the old creation-time path this
  // only fires when the user explicitly asks (a button), so it never adds a
  // concurrent LLM request to the task-creation hot path. Owner/admin only.
  app.post('/api/local/tasks/:taskId/regenerate-title', 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;
      if (!opts.generateTitle) { res.status(503).json({ error: 'Title generation is not configured' }); return; }

      let title = '';
      try {
        title = await Promise.race([
          // Ownerless (no-auth) tasks attribute to 'local', matching the
          // worker/piece-runner convention (ownerId ?? 'local').
          opts.generateTitle(task!.body, task!.ownerId ?? 'local'),
          new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
        ]);
      } catch (e) {
        logger.warn(`Title regeneration failed (task=${taskId}): ${e}`);
        res.status(502).json({ error: 'Title generation failed' }); return;
      }
      // Empty model output is not an error: fall back to the cheap synchronous
      // title so the button always yields something (matching the old creation
      // path's behaviour).
      title = (title ?? '').trim() || buildTitleFallback(task!.body);
      await repo.updateLocalTask(taskId, { title, titleSource: 'agent' });
      res.json({ title });
    } catch (err) {
      logger.error(`Regenerate title API error: ${err}`);
      res.status(500).json({ error: 'Failed to regenerate title' });
    }
  });

  // On-demand prompt coach. Evaluates a draft prompt before the task is
  // created (stateless), so it never touches the DB or piece-runner. The owner
  // context (memory / AGENTS.md / skills / visible pieces) is keyed on the
  // requesting user, falling back to 'local' for unauthenticated/no-auth calls.
  app.post('/api/local/tasks/evaluate-prompt', dynamicJson, async (req: Request, res: Response) => {
    try {
      if (!opts.evaluatePrompt) {
        res.status(503).json({ error: 'Prompt coach is not configured' });
        return;
      }
      const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction : '';
      if (!instruction.trim()) {
        res.status(400).json({ error: 'instruction is required' });
        return;
      }
      const piece = typeof req.body?.piece === 'string' ? req.body.piece : undefined;
      const userId = (req.user as Express.User | undefined)?.id ?? 'local';
      // Abort the underlying LLM stream on timeout so a slow model doesn't keep
      // consuming tokens/connections in the background after we've given up.
      const controller = new AbortController();
      let timer: ReturnType<typeof setTimeout> | undefined;
      const timeoutMs = opts.evaluatePromptTimeoutMs ?? 30000;
      const timeout = new Promise<never>((_, reject) => {
        timer = setTimeout(() => {
          controller.abort();
          reject(new Error('timeout'));
        }, timeoutMs);
      });
      try {
        const result = await Promise.race([
          opts.evaluatePrompt({ instruction, piece, userId, signal: controller.signal }),
          timeout,
        ]);
        res.json(result);
      } finally {
        if (timer) clearTimeout(timer);
      }
    } catch (err) {
      logger.warn(`Prompt coach evaluation failed: ${err}`);
      res.status(502).json({ error: 'Prompt evaluation failed' });
    }
  });
}
