import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router';
import { X, Play, Square, CircleCheck, CircleX, Copy, Check, Loader2, AlertCircle, ExternalLink } from 'lucide-react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import type { TaskInfo, ChatMessage } from '../../../Chat';
import { useChat, MessageBubble, StreamingBubble, ModelSelector } from '../../../Chat';
import { useSettings } from 'state/useSettings';
import { useUserVisibleModels } from 'state/useModels';
import { useClient } from 'hooks/useClient';
import type { TaskSummary } from '../../useTasks';
import { useTaskRunner } from './useTaskRunner';
import { usePipelineRunner } from './usePipelineRunner';
import {
  useFilesAPI,
  type AudioTrack,
  type AudioMeta,
  type SubtitleTrack,
  type FolderProbe,
  type FolderTrackGroup,
} from '../../../../hooks/useFilesAPI';
import { randomId } from 'helpers/random-id';
import { copyToClipboard } from 'helpers/clipboard';

const playDing = () => {
  const ctx = new AudioContext();
  const t = ctx.currentTime;

  const play = (freq: number, start: number, dur: number) => {
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.type = 'sine';
    osc.frequency.setValueAtTime(freq, t + start);
    gain.gain.setValueAtTime(0.2, t + start);
    gain.gain.setValueAtTime(0.2, t + start + dur * 0.6);
    gain.gain.exponentialRampToValueAtTime(0.001, t + start + dur);
    osc.connect(gain).connect(ctx.destination);
    osc.start(t + start);
    osc.stop(t + start + dur);
  };

  play(880, 0, 0.4);
  play(1100, 0.25, 0.4);
  play(1320, 0.5, 0.6);

  setTimeout(() => ctx.close(), 1500);
};

type Phase = 'ready' | 'running' | 'done';

type AgenticTaskRunnerProps = {
  taskDirName: string;
  defaultInput: string;
  cwd: { root?: string; path: string };
  initialModel: string | null;
  taskInfo: TaskInfo;
  context: Record<string, string>;
};

const AgenticTaskRunner = ({
  taskDirName,
  defaultInput,
  cwd,
  initialModel,
  taskInfo,
  context,
}: AgenticTaskRunnerProps) => {
  const [phase, setPhase] = useState<Phase>('ready');
  const chat = useChat(undefined, initialModel, { replaceUrl: false, taskInfo });
  const availableModels = useUserVisibleModels();
  const client = useClient();
  const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
  const [formValues, setFormValues] = useState<Record<string, string>>({});
  const [taskBody, setTaskBody] = useState<string | null>(null);

  // Fetch task detail for inputs and body
  useEffect(() => {
    client.get<{ inputs?: Record<string, TaskInputDef>; body?: string }>(`/tasks/${taskDirName}`).then((task) => {
      const defs: Record<string, TaskInputDef> = task.inputs ?? {};
      setInputDefs(defs);
      setTaskBody(task.body ?? null);
      // Initialize from defaults and autofill
      const initial: Record<string, string> = {};
      for (const [key, def] of Object.entries(defs)) {
        if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
        else if (def.default !== undefined) initial[key] = def.default;
      }
      setFormValues(initial);
    });
  }, [taskDirName]);

  const handleInputChange = (key: string, value: string) => {
    setFormValues((prev) => ({ ...prev, [key]: value }));
  };

  // --- Independent message accumulator (never loses messages) ---
  const accRef = useRef<ChatMessage[]>([]);
  const seenToolIdsRef = useRef(new Set<string>());
  const seenResultRef = useRef(false);
  const [, bump] = useState(0);

  // Track tool/result/error messages from chat.messages (idempotent during render)
  for (const m of chat.messages) {
    if (m.role === 'tool' && 'toolCallId' in m) {
      if (!seenToolIdsRef.current.has(m.toolCallId)) {
        seenToolIdsRef.current.add(m.toolCallId);
        accRef.current.push(m);
      } else {
        // Update existing tool message (e.g. output arrived)
        const idx = accRef.current.findIndex(
          (a) => a.role === 'tool' && 'toolCallId' in a && a.toolCallId === m.toolCallId,
        );
        if (idx >= 0) accRef.current[idx] = m;
      }
    }
    if (m.role === 'result' && !seenResultRef.current) {
      seenResultRef.current = true;
      accRef.current.push(m);
    }
    if (m.role === 'error') {
      const alreadyHas = accRef.current.some((a) => a.role === 'error' && 'text' in a && a.text === m.text);
      if (!alreadyHas) accRef.current.push(m);
    }
  }

  // Capture assistant text when streaming is committed (streamingText goes non-empty → empty)
  const lastStreamRef = useRef('');
  useEffect(() => {
    if (lastStreamRef.current && !chat.streamingText) {
      const text = lastStreamRef.current;
      const isDuplicate = accRef.current.some((a) => a.role === 'assistant' && 'text' in a && a.text === text);
      if (!isDuplicate) {
        accRef.current.push({ role: 'assistant', id: randomId(), text });
        bump((n) => n + 1);
      }
      lastStreamRef.current = '';
    }
    if (chat.streamingText) {
      lastStreamRef.current = chat.streamingText;
    }
  }, [chat.streamingText]);

  // Auto-scroll
  const bottomRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [chat.messages, chat.streamingText]);

  // Ding on completion + transition to done
  const wasGenerating = useRef(false);
  useEffect(() => {
    if (wasGenerating.current && !chat.isGenerating) {
      playDing();
      setPhase('done');
    }
    wasGenerating.current = chat.isGenerating;
  }, [chat.isGenerating]);

  const handleRun = () => {
    // Build prompt from task body + input values, or fall back to generic prompt
    let prompt = defaultInput;
    if (taskBody && inputDefs) {
      const inputLines = Object.entries(formValues)
        .filter(([, v]) => v.trim())
        .map(([key, value]) => {
          const label = inputDefs[key]?.description ?? key;
          return `- **${label}**: ${value}`;
        })
        .join('\n');
      const contextLines: string[] = [];
      if (context.entry_path) contextLines.push(`- **Target directory**: ${context.entry_path}`);
      const contextSection = contextLines.length > 0 ? `\n\n## Context\n\n${contextLines.join('\n')}` : '';
      prompt = `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
    }
    setPhase('running');
    chat.sendPrompt(prompt, undefined, undefined, cwd, undefined);
  };

  const hasConfigurableInputs = inputDefs && Object.keys(inputDefs).length > 0;

  if (phase === 'ready') {
    return (
      <>
        {hasConfigurableInputs && (
          <TaskInputForm
            inputDefs={inputDefs}
            values={formValues}
            onChange={handleInputChange}
            autoFilledKeys={new Set()}
          />
        )}
        <div className="flex-1 flex items-center justify-center">
          <button
            onClick={handleRun}
            disabled={!chat.isConnected || !inputDefs}
            className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
          >
            <Play className="h-4 w-4" />
            Run
          </button>
        </div>
        <div className="shrink-0 px-4 py-3 border-t border-duck-dark/10">
          <ModelSelector
            messages={[]}
            availableModels={availableModels}
            selectedModel={chat.selectedModel}
            onModelChange={chat.setSelectedModel}
            model={chat.model}
            isConnected={chat.isConnected}
            isGenerating={false}
            hasStarted={false}
            thinkingLevel={chat.thinkingLevel}
            onThinkingChange={chat.setThinkingLevel}
          />
        </div>
      </>
    );
  }

  // Show StreamingBubble with fallback: use lastStreamRef while the effect hasn't captured yet
  const showStream = chat.streamingText || lastStreamRef.current;

  return (
    <div className="flex-1 flex flex-col min-h-0">
      <div className="flex-1 min-h-0 overflow-y-auto">
        {accRef.current.map((msg, i) => (
          <div key={i} className="px-4 py-1.5">
            <MessageBubble message={msg} onAnswer={() => {}} />
          </div>
        ))}
        {showStream && (
          <div className="px-4 py-1.5">
            <StreamingBubble text={showStream} />
          </div>
        )}
        <div ref={bottomRef} />
      </div>
      <div className="shrink-0 flex justify-center py-3 border-t border-duck-dark/10">
        {phase === 'running' ? (
          <button
            onClick={chat.stopGeneration}
            className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
          >
            <Square className="h-3.5 w-3.5" />
            Stop
          </button>
        ) : accRef.current.some((m) => m.role === 'error') ? (
          <span className="flex items-center gap-2 text-sm text-red-500 font-medium">
            <CircleX className="h-4 w-4" />
            Task failed
          </span>
        ) : (
          <span className="flex items-center gap-2 text-sm text-green-500 font-medium">
            <CircleCheck className="h-4 w-4" />
            Task complete
          </span>
        )}
      </div>
    </div>
  );
};

// ── Task input definitions ──

// Exported: the agent runner renders the same form for an agent's optional inputs. Agents never
// declare the media-probe input types, so those branches simply don't fire there.
export type TaskInputDef = {
  type: string;
  description?: string;
  default?: string;
  options?: string[];
  autofill?: string;
};

type TaskInputFormProps = {
  inputDefs: Record<string, TaskInputDef>;
  values: Record<string, string>;
  onChange: (key: string, value: string) => void;
  autoFilledKeys: Set<string>;
  audioTracks?: AudioTrack[];
  subtitleTracks?: SubtitleTrack[];
  probing?: boolean;
  entryType?: 'file' | 'directory';
  hideTrackPickers?: boolean;
};

const trackLabel = (t: { title: string; lang: string; id: number }) =>
  t.title || (t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '') || `Track ${t.id + 1}`;

// Channel count → friendly layout name; audio meta → "stereo · aac 193k"-style detail for a track row.
const chLabel = (n: number) =>
  n === 1 ? 'mono' : n === 2 ? 'stereo' : n === 6 ? '5.1' : n === 8 ? '7.1' : n ? `${n}ch` : '';
const audioMeta = (t: AudioTrack) =>
  [chLabel(t.channels), t.codec].filter(Boolean).join(' ') + (t.bitrate ? ` ${t.bitrate}k` : '');

// subtitle_edit input: per-subtitle keep flag + editable label, serialized to JSON in the form value.
type SubtitleEditEntry = { id: number; keep: boolean; label: string };

const parseSubtitleSpec = (raw?: string): SubtitleEditEntry[] => {
  if (!raw) return [];
  try {
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
};

export const TaskInputForm = ({
  inputDefs,
  values,
  onChange,
  autoFilledKeys,
  audioTracks,
  subtitleTracks,
  probing,
  entryType,
  hideTrackPickers,
}: TaskInputFormProps) => {
  const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key));
  if (configurableInputs.length === 0) return null;

  return (
    <div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
      {configurableInputs.map(([key, def]) => {
        if (def.type === 'subtitle_edit') {
          const tracks = subtitleTracks ?? [];
          const byId = new Map(parseSubtitleSpec(values[key]).map((e) => [e.id, e]));
          const entryFor = (t: SubtitleTrack): SubtitleEditEntry =>
            byId.get(t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
          const update = (id: number, patch: Partial<SubtitleEditEntry>) =>
            onChange(
              key,
              JSON.stringify(tracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t)))),
            );
          const keptCount = tracks.filter((t) => entryFor(t).keep).length;
          return (
            <div key={key} className="flex flex-col gap-1.5">
              <span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
              {probing ? (
                <span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
                  <Loader2 className="h-3 w-3 animate-spin" />{' '}
                  {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
                </span>
              ) : tracks.length === 0 ? (
                <span className="text-xs text-duck-dark/50 dark:text-foreground/50">No subtitles</span>
              ) : (
                <>
                  <div className="flex flex-col gap-1 max-h-72 overflow-y-auto pr-1">
                    {tracks.map((t) => {
                      const e = entryFor(t);
                      return (
                        <div key={t.id} className="flex items-center gap-2">
                          <label className="flex items-center gap-2 cursor-pointer shrink-0">
                            <input
                              type="checkbox"
                              checked={e.keep}
                              onChange={() => update(t.id, { keep: !e.keep })}
                              className="accent-duck-teal cursor-pointer"
                            />
                            <span className="text-[10px] font-mono uppercase w-9 text-duck-dark/40 dark:text-foreground/40">
                              {t.lang || 'und'}
                            </span>
                          </label>
                          <input
                            type="text"
                            value={e.label}
                            disabled={!e.keep}
                            onChange={(ev) => update(t.id, { label: ev.target.value })}
                            className="flex-1 min-w-0 px-2 py-1 text-sm rounded-md border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal disabled:opacity-40"
                          />
                        </div>
                      );
                    })}
                  </div>
                  <span className="text-xs text-duck-dark/40 dark:text-foreground/40">
                    Keeping {keptCount} of {tracks.length}
                  </span>
                </>
              )}
            </div>
          );
        }

        if (def.type === 'audio_tracks' || def.type === 'subtitle_tracks') {
          if (hideTrackPickers) return null; // "convert all, keep every track" mode — selection doesn't apply
          const isAudio = def.type === 'audio_tracks';
          const tracks = (isAudio ? audioTracks : subtitleTracks) ?? [];
          const selected = new Set(
            (values[key] ?? '')
              .split(',')
              .filter(Boolean)
              .map(Number)
              .filter((n) => !Number.isNaN(n)),
          );
          const toggle = (id: number) => {
            const next = new Set(selected);
            if (next.has(id)) next.delete(id);
            else next.add(id);
            const ids = [...next].sort((a, b) => a - b);
            onChange(key, ids.length > 0 ? ids.join(',') : 'none');
          };
          return (
            <div key={key} className="flex flex-col gap-1.5">
              <span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
              {probing ? (
                <span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
                  <Loader2 className="h-3 w-3 animate-spin" />{' '}
                  {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
                </span>
              ) : tracks.length === 0 ? (
                <span className="text-xs text-duck-dark/50 dark:text-foreground/50">None</span>
              ) : (
                <div className="flex flex-wrap gap-x-3 gap-y-1.5">
                  {tracks.map((t) => (
                    <label
                      key={t.id}
                      className="flex items-center gap-1.5 text-sm cursor-pointer text-duck-dark dark:text-foreground"
                    >
                      <input
                        type="checkbox"
                        checked={selected.has(t.id)}
                        onChange={() => toggle(t.id)}
                        className="accent-duck-teal cursor-pointer"
                      />
                      <span className="whitespace-nowrap">
                        {trackLabel(t)}
                        {isAudio && (
                          <span className="text-duck-dark/40 dark:text-foreground/40">
                            {' '}
                            · {(t as AudioTrack).channels}ch
                          </span>
                        )}
                      </span>
                    </label>
                  ))}
                </div>
              )}
            </div>
          );
        }

        if (def.type === 'boolean') {
          const isTrue = values[key] === 'true';
          return (
            <div key={key} className="flex items-center justify-between gap-4">
              <div className="min-w-0">
                <span className="text-sm font-medium text-duck-dark dark:text-foreground">
                  {def.description ?? key}
                </span>
              </div>
              <div className="flex items-center gap-1 shrink-0 bg-duck-dark/5 dark:bg-foreground/5 rounded-lg p-0.5">
                <button
                  onClick={() => onChange(key, 'false')}
                  className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${!isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
                >
                  No
                </button>
                <button
                  onClick={() => onChange(key, 'true')}
                  className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${isTrue ? 'bg-background shadow-sm text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground'}`}
                >
                  Yes
                </button>
              </div>
            </div>
          );
        }

        if (def.type === 'text') {
          return (
            <div key={key} className="flex flex-col gap-1">
              <label className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">
                {def.description ?? key}
              </label>
              <textarea
                value={values[key] ?? ''}
                onChange={(e) => onChange(key, e.target.value)}
                rows={6}
                className="px-3 py-1.5 text-sm rounded-lg border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal resize-y whitespace-pre-wrap"
              />
            </div>
          );
        }

        if (def.options && def.options.length > 0) {
          const selected = values[key] ?? def.default ?? def.options[0]!;
          return (
            <div key={key} className="flex flex-col gap-1.5">
              <span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
              <div className="flex flex-wrap gap-1.5">
                {def.options.map((opt) => (
                  <button
                    key={opt}
                    onClick={() => onChange(key, opt)}
                    className={`px-3 py-1 text-xs font-medium rounded-md transition-colors cursor-pointer ${selected === opt ? 'bg-duck-teal text-white' : 'bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/60 dark:text-foreground/60 hover:text-duck-dark dark:hover:text-foreground hover:bg-duck-dark/10 dark:hover:bg-foreground/10'}`}
                  >
                    {opt}
                  </button>
                ))}
              </div>
            </div>
          );
        }

        // Default: text input for string/number
        return (
          <div key={key} className="flex flex-col gap-1">
            <label className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">
              {def.description ?? key}
            </label>
            <input
              type={def.type === 'number' ? 'number' : 'text'}
              value={values[key] ?? ''}
              onChange={(e) => onChange(key, e.target.value)}
              className="px-3 py-1.5 text-sm rounded-lg border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal"
            />
          </div>
        );
      })}
    </div>
  );
};

// ── Get Lyrics info panel (read-only audio metadata) ──

const fmtDuration = (d: number): string => {
  if (!d || d <= 0) return '';
  const s = Math.round(d);
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};

const AudioMetaPanel = ({ meta, fileName }: { meta: AudioMeta; fileName: string }) => {
  const title = meta.title || fileName;
  const dur = fmtDuration(meta.duration);
  return (
    <div className="px-5 py-3 border-b border-duck-dark/10">
      <div className="truncate text-sm font-medium text-duck-dark dark:text-foreground">{title}</div>
      {meta.artist ? (
        <div className="truncate text-xs text-duck-dark/60 dark:text-foreground/60">{meta.artist}</div>
      ) : null}
      <div className="mt-1.5 flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
        {dur ? <span className="tabular-nums">{dur}</span> : null}
        <span
          className={`rounded-md px-1.5 py-0.5 font-medium ${
            meta.hasLyrics
              ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
              : 'bg-duck-dark/10 dark:bg-foreground/10'
          }`}
        >
          Lyrics: {meta.hasLyrics ? 'Yes' : 'No'}
        </span>
      </div>
    </div>
  );
};

// ── Folder-mode summary (batch track selection) ──

type FolderSummaryProps = {
  folder: { fileCount: number; majorityCount: number; skipped: string[] };
  keepAll?: boolean;
  // When provided (mixed-layout folder with track pickers), offers the "convert all, keep every
  // track" escape hatch instead of only converting the matching group.
  onKeepAllChange?: (v: boolean) => void;
};

const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSummaryProps) => {
  const { fileCount, majorityCount, skipped } = folder;

  if (fileCount === 0) {
    return (
      <div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
        <AlertCircle className="h-4 w-4 shrink-0" /> No videos found in this folder.
      </div>
    );
  }

  if (skipped.length === 0) {
    return (
      <div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-duck-dark/70 dark:text-foreground/70">
        <CircleCheck className="h-4 w-4 shrink-0 text-duck-teal" />
        All {fileCount} videos share the same track layout — settings apply to every file.
      </div>
    );
  }

  // Mixed layouts — no toggle available: just report the majority run + skipped list.
  if (!onKeepAllChange) {
    return (
      <div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-1.5">
        <span className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
          <AlertCircle className="h-4 w-4 shrink-0" />
          {majorityCount} of {fileCount} videos share this layout and will be processed.
        </span>
        <div className="text-xs text-duck-dark/50 dark:text-foreground/50">
          <span>Different layout — run these separately:</span>
          <ul className="mt-1 max-h-24 overflow-y-auto flex flex-col gap-0.5 pl-1">
            {skipped.map((f) => (
              <li key={f} className="truncate">
                • {f}
              </li>
            ))}
          </ul>
        </div>
      </div>
    );
  }

  return (
    <div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-2">
      <span className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
        <AlertCircle className="h-4 w-4 shrink-0" />
        {majorityCount} of {fileCount} videos share a track layout.
      </span>
      <div className="flex flex-col gap-1.5 text-sm text-duck-dark dark:text-foreground">
        <label className="flex items-start gap-2 cursor-pointer">
          <input
            type="radio"
            checked={!keepAll}
            onChange={() => onKeepAllChange(false)}
            className="accent-duck-teal cursor-pointer mt-0.5"
          />
          <span>
            Convert the {majorityCount} matching
            <span className="text-xs text-duck-dark/50 dark:text-foreground/50">
              {' '}
              — pick tracks below; the other {skipped.length} are skipped
            </span>
          </span>
        </label>
        <label className="flex items-start gap-2 cursor-pointer">
          <input
            type="radio"
            checked={keepAll}
            onChange={() => onKeepAllChange(true)}
            className="accent-duck-teal cursor-pointer mt-0.5"
          />
          <span>
            Convert all {fileCount} — keep every track
            <span className="text-xs text-duck-dark/50 dark:text-foreground/50"> — nothing skipped</span>
          </span>
        </label>
      </div>
      {!keepAll && (
        <div className="text-xs text-duck-dark/50 dark:text-foreground/50">
          <span>Skipped (convert separately):</span>
          <ul className="mt-1 max-h-20 overflow-y-auto flex flex-col gap-0.5 pl-1">
            {skipped.map((f) => (
              <li key={f} className="truncate">
                • {f}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
};

// ── Per-group track config (one run handles every layout) ──

// Which per-group pickers a task declares.
type PickerKinds = { audio: boolean; subs: boolean; subEdit: boolean };
// Per-group selection: audio/subs are kept-id csv (or 'none'); subEdit is the keep+label list.
type GroupSel = { audio: string; subs: string; subEdit: SubtitleEditEntry[] };

const parseCsv = (csv: string) =>
  new Set(
    csv
      .split(',')
      .filter(Boolean)
      .map(Number)
      .filter((n) => !Number.isNaN(n)),
  );

const pickerKinds = (defs: Record<string, TaskInputDef> | null): PickerKinds => ({
  audio: Object.values(defs ?? {}).some((d) => d.type === 'audio_tracks'),
  subs: Object.values(defs ?? {}).some((d) => d.type === 'subtitle_tracks'),
  subEdit: Object.values(defs ?? {}).some((d) => d.type === 'subtitle_edit'),
});

const defaultGroupSel = (g: FolderTrackGroup): GroupSel => ({
  audio: g.audioTracks.map((t) => t.id).join(','), // audio: keep all
  subs: 'none', // subtitles: keep none by default — you usually want only a few of many
  subEdit: g.subtitleTracks.map((t) => ({ id: t.id, keep: false, label: trackLabel(t) })),
});

// Does a group's selection change anything vs keep-all + original labels?
const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boolean => {
  if (has.audio && (s.audio === 'none' ? g.audioTracks.length > 0 : parseCsv(s.audio).size < g.audioTracks.length))
    return true;
  if (has.subs && (s.subs === 'none' ? g.subtitleTracks.length > 0 : parseCsv(s.subs).size < g.subtitleTracks.length))
    return true;
  if (has.subEdit) {
    for (const t of g.subtitleTracks) {
      const e = s.subEdit.find((x) => x.id === t.id);
      if (!e || !e.keep || e.label !== trackLabel(t)) return true;
    }
  }
  return false;
};

// Build the JSON group config the scripts consume ($INPUT_GROUP_CONFIG). allFiles=true (convert) keeps
// every group; otherwise only groups that actually change are included.
const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: PickerKinds, allFiles: boolean) => {
  const spec = (csv: string, count: number) =>
    csv === 'none' ? 'none' : parseCsv(csv).size >= count ? 'all' : [...parseCsv(csv)].sort((a, b) => a - b).join(',');
  return groups
    .map((g, gi) => ({ g, s: sel[gi] ?? defaultGroupSel(g) }))
    .filter(({ g, s }) => allFiles || groupChanges(g, s, has))
    .map(({ g, s }) => ({
      files: g.files,
      ...(has.audio ? { audio: spec(s.audio, g.audioTracks.length) } : {}),
      ...(has.subs ? { subs: spec(s.subs, g.subtitleTracks.length) } : {}),
      ...(has.subEdit
        ? {
            subEdit: g.subtitleTracks.map(
              (t) => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) },
            ),
          }
        : {}),
    }));
};

type PerGroupTrackConfigProps = {
  groups: FolderTrackGroup[];
  has: PickerKinds;
  sel: GroupSel[];
  onChange: (groupIdx: number, patch: Partial<GroupSel>) => void;
  probing?: boolean;
};

const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTrackConfigProps) => {
  if (probing) {
    return (
      <div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
        <Loader2 className="h-3 w-3 animate-spin" /> Analyzing files…
      </div>
    );
  }
  if (groups.length === 0) {
    return (
      <div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
        <AlertCircle className="h-4 w-4 shrink-0" /> No videos found in this folder.
      </div>
    );
  }
  const label = (kind: string) => (
    <span className="text-[11px] font-medium uppercase tracking-wide text-duck-dark/40 dark:text-foreground/40">
      {kind}
    </span>
  );
  return (
    <div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
      <span className="text-sm font-medium text-duck-dark dark:text-foreground">
        {groups.length} track layout{groups.length !== 1 ? 's' : ''} — configure each; all groups run in one pass.
      </span>
      {groups.map((g, gi) => {
        const s = sel[gi] ?? defaultGroupSel(g);
        const aSel = parseCsv(s.audio);
        const sSel = parseCsv(s.subs);
        const toggleA = (id: number) => {
          const n = new Set(aSel);
          n.has(id) ? n.delete(id) : n.add(id);
          onChange(gi, { audio: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' });
        };
        const toggleS = (id: number) => {
          const n = new Set(sSel);
          n.has(id) ? n.delete(id) : n.add(id);
          onChange(gi, { subs: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' });
        };
        const entryFor = (t: SubtitleTrack): SubtitleEditEntry =>
          s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
        const updateEdit = (id: number, patch: Partial<SubtitleEditEntry>) =>
          onChange(gi, {
            subEdit: g.subtitleTracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t))),
          });
        return (
          <div
            key={g.signature + gi}
            className="flex flex-col gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
          >
            <span className="text-sm font-medium text-duck-dark dark:text-foreground">
              Group {gi + 1}
              <span className="text-duck-dark/50 dark:text-foreground/50 font-normal">
                {' '}
                · {g.count} file{g.count !== 1 ? 's' : ''}
              </span>
            </span>

            {has.audio && (
              <div className="flex flex-col gap-1">
                {label('audio')}
                {g.audioTracks.length === 0 ? (
                  <span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
                ) : (
                  g.audioTracks.map((t) => (
                    <label
                      key={t.id}
                      className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground"
                    >
                      <input
                        type="checkbox"
                        checked={aSel.has(t.id)}
                        onChange={() => toggleA(t.id)}
                        className="accent-duck-teal cursor-pointer shrink-0"
                      />
                      <span className="whitespace-nowrap">
                        {trackLabel(t)}
                        <span className="text-duck-dark/40 dark:text-foreground/40"> · {audioMeta(t)}</span>
                      </span>
                    </label>
                  ))
                )}
              </div>
            )}

            {has.subs && (
              <div className="flex flex-col gap-1">
                {label('subtitles')}
                {g.subtitleTracks.length === 0 ? (
                  <span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
                ) : (
                  g.subtitleTracks.map((t) => (
                    <label
                      key={t.id}
                      className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground"
                    >
                      <input
                        type="checkbox"
                        checked={sSel.has(t.id)}
                        onChange={() => toggleS(t.id)}
                        className="accent-duck-teal cursor-pointer shrink-0"
                      />
                      <span className="whitespace-nowrap">
                        {trackLabel(t)}
                        <span className="text-duck-dark/40 dark:text-foreground/40"> · {t.codec}</span>
                      </span>
                    </label>
                  ))
                )}
              </div>
            )}

            {has.subEdit && (
              <div className="flex flex-col gap-1">
                {label('subtitles')}
                {g.subtitleTracks.length === 0 ? (
                  <span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
                ) : (
                  g.subtitleTracks.map((t) => {
                    const e = entryFor(t);
                    return (
                      <div key={t.id} className="flex items-center gap-2">
                        <label className="flex items-center gap-2 cursor-pointer shrink-0">
                          <input
                            type="checkbox"
                            checked={e.keep}
                            onChange={() => updateEdit(t.id, { keep: !e.keep })}
                            className="accent-duck-teal cursor-pointer"
                          />
                          <span className="text-[10px] font-mono uppercase w-9 text-duck-dark/40 dark:text-foreground/40">
                            {t.lang || 'und'}
                          </span>
                        </label>
                        <input
                          type="text"
                          value={e.label}
                          disabled={!e.keep}
                          onChange={(ev) => updateEdit(t.id, { label: ev.target.value })}
                          className="flex-1 min-w-0 px-2 py-1 text-sm rounded-md border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal disabled:opacity-40"
                        />
                      </div>
                    );
                  })
                )}
              </div>
            )}

            <details className="text-xs text-duck-dark/40 dark:text-foreground/40">
              <summary className="cursor-pointer select-none">files</summary>
              <ul className="mt-1 max-h-24 overflow-y-auto flex flex-col gap-0.5 pl-1">
                {g.files.map((f) => (
                  <li key={f} className="truncate">
                    • {f}
                  </li>
                ))}
              </ul>
            </details>
          </div>
        );
      })}
    </div>
  );
};

// ── Script-mode runner ──

type ScriptRunnerProps = {
  taskDirName: string;
  autoInputs: Record<string, string>;
  context: Record<string, string>;
  cwd?: string;
  entryType?: 'file' | 'directory';
  filePath?: string;
  selectedNames?: string[];
  onClose: () => void;
};

const ScriptRunner = ({
  taskDirName,
  autoInputs,
  context,
  cwd,
  entryType,
  filePath,
  selectedNames,
  onClose,
}: ScriptRunnerProps) => {
  const runner = useTaskRunner();
  const client = useClient();
  const navigate = useNavigate();
  const files = useFilesAPI('home');
  // How this task runs: false = job, true = ephemerally in this modal, 'ask' = the task leaves the
  // choice to the user and both affordances are offered.
  const [inlineMode, setInlineMode] = useState<boolean | 'ask'>(false);
  const alwaysInline = inlineMode === true;
  const canChooseRunMode = inlineMode === 'ask';
  // Is a job already running? (drives the Run vs Queue affordance).
  const [jobRunning, setJobRunning] = useState(false);
  // After a job is created, show a confirmation (View / Close) rather than navigating away.
  const [created, setCreated] = useState<{ jobId: string; action: 'start' | 'queue' } | null>(null);
  const bottomRef = useRef<HTMLDivElement | null>(null);
  const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
  const [formValues, setFormValues] = useState<Record<string, string>>({});
  const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
  const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
  // Get Lyrics: read-only audio metadata for the info panel (single file only).
  const [audioMeta, setAudioMeta] = useState<AudioMeta | null>(null);
  const [probing, setProbing] = useState(false);
  // Folder mode: how many videos share the chosen layout, and which ones don't (converted separately).
  const [folder, setFolder] = useState<{ fileCount: number; majorityCount: number; skipped: string[] } | null>(null);
  // Newline-separated folder-relative paths to restrict a batch to the majority group (empty = all).
  const [includeFiles, setIncludeFiles] = useState('');
  // Mixed-layout escape hatch: convert every video keeping all tracks (no picking, nothing skipped).
  const [keepAll, setKeepAll] = useState(false);
  // Task opts into the folder "keep every track, run over all" escape hatch via config.folderKeepAll.
  const [folderKeepAll, setFolderKeepAll] = useState(false);
  // Task opts into per-group track config (one run, each layout its own selection) via config.perGroupTracks.
  const [perGroupTracks, setPerGroupTracks] = useState(false);
  // perGroupAllFiles: process every file even when a group keeps everything (convert — a format change
  // is still work). Otherwise only groups that actually change are run (track-only edits).
  const [perGroupAllFiles, setPerGroupAllFiles] = useState(false);
  const [allGroups, setAllGroups] = useState<FolderTrackGroup[]>([]);
  const [groupSel, setGroupSel] = useState<GroupSel[]>([]);

  // Fetch task detail to get input definitions
  useEffect(() => {
    client
      .get<{
        inline?: boolean | 'ask';
        inputs?: Record<string, TaskInputDef>;
        config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean };
      }>(`/tasks/${taskDirName}`)
      .then((task) => {
        const defs: Record<string, TaskInputDef> = task.inputs ?? {};
        setInlineMode(task.inline === 'ask' ? 'ask' : task.inline === true);
        setFolderKeepAll(task.config?.folderKeepAll === true);
        setPerGroupTracks(task.config?.perGroupTracks === true);
        setPerGroupAllFiles(task.config?.perGroupAllFiles === true);
        setInputDefs(defs);
        // Initialize from autofill context, then defaults
        const initial: Record<string, string> = {};
        for (const [key, def] of Object.entries(defs)) {
          if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
          else if (def.default !== undefined) initial[key] = def.default;
        }
        setFormValues(initial);

        // Multi-selection: scope the run to exactly the selected files. Even tasks without pickers
        // (which return early below) need this so the batch only touches the selection.
        if (selectedNames && selectedNames.length > 0) setIncludeFiles(selectedNames.join('\n'));

        // Get Lyrics: prefetch read-only audio metadata for the info panel (single file only), regardless
        // of track pickers (it has none, so it returns early below). Errors are tolerated — never blocks.
        setAudioMeta(null);
        if (taskDirName === 'get-lyrics' && filePath && entryType !== 'directory') {
          files
            .audioMeta(filePath)
            .then(setAudioMeta)
            .catch(() => setAudioMeta(null));
        }

        // Track pickers: audio defaults to keep-all, subtitles to keep-none. A folder probes every
        // video and drives the pickers off the largest matching group (the rest convert separately).
        const hasTrackPickers = Object.values(defs).some(
          (d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks' || d.type === 'subtitle_edit',
        );
        if (!hasTrackPickers || !filePath) return;

        const seedTracks = (aud: AudioTrack[], sub: SubtitleTrack[]) => {
          setAudioTracks(aud);
          setSubtitleTracks(sub);
          setFormValues((prev) => {
            const next = { ...prev };
            for (const [key, def] of Object.entries(defs)) {
              if (def.type === 'audio_tracks') next[key] = aud.map((t) => t.id).join(',') || 'none';
              if (def.type === 'subtitle_tracks') next[key] = 'none';
              if (def.type === 'subtitle_edit')
                next[key] = JSON.stringify(sub.map((t) => ({ id: t.id, keep: false, label: trackLabel(t) })));
            }
            return next;
          });
        };

        setProbing(true);
        if (entryType === 'directory') {
          files
            .probeFolder(filePath)
            .then((probe: FolderProbe) => {
              // Restrict grouping to the selection when this is a multi-selection run — a file matches
              // if it's selected directly, or sits under a selected folder.
              const sel = selectedNames && selectedNames.length > 0 ? new Set(selectedNames) : null;
              const inSel = (f: string) => {
                if (sel!.has(f)) return true;
                let p = f;
                for (let i = p.lastIndexOf('/'); i >= 0; i = p.lastIndexOf('/')) {
                  p = p.slice(0, i);
                  if (sel!.has(p)) return true;
                }
                return false;
              };
              const groups = sel
                ? probe.groups
                    .map((g) => ({ ...g, files: g.files.filter(inSel) }))
                    .filter((g) => g.files.length > 0)
                    .map((g) => ({ ...g, count: g.files.length }))
                    .sort((a, b) => b.count - a.count)
                : probe.groups;
              const fileCount = sel ? groups.reduce((n, g) => n + g.files.length, 0) : probe.fileCount;

              // Per-group mode uses every group with its own pickers, each defaulting to keep-all.
              setAllGroups(groups);
              setGroupSel(groups.map(defaultGroupSel));

              const majority = groups[0];
              if (!majority) {
                setFolder({ fileCount, majorityCount: 0, skipped: [] });
                return;
              }
              seedTracks(majority.audioTracks, majority.subtitleTracks);
              const skipped = groups.slice(1).flatMap((g) => g.files);
              setFolder({ fileCount, majorityCount: majority.count, skipped });
              // Pin the include list when leaving files out — always, for a multi-selection (so it
              // never spills onto unselected files), otherwise only on a mixed-layout folder.
              setIncludeFiles(sel || skipped.length > 0 ? majority.files.join('\n') : '');
            })
            .catch(() => setFolder({ fileCount: 0, majorityCount: 0, skipped: [] }))
            .finally(() => setProbing(false));
        } else {
          Promise.all([
            files.audioTracks(filePath).catch(() => [] as AudioTrack[]),
            files.subtitles(filePath).catch(() => [] as SubtitleTrack[]),
          ])
            .then(([aud, sub]: [AudioTrack[], SubtitleTrack[]]) => seedTracks(aud, sub))
            .finally(() => setProbing(false));
        }
      });
  }, [taskDirName]);

  const autoFilledKeys = new Set(Object.keys(autoInputs));

  const handleInputChange = (key: string, value: string) => {
    setFormValues((prev) => ({ ...prev, [key]: value }));
  };

  // Auto-scroll
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [runner.output]);

  // Ding on completion
  const prevPhaseRef = useRef(runner.phase);
  useEffect(() => {
    if (prevPhaseRef.current === 'running' && runner.phase === 'done') {
      playDing();
    }
    prevPhaseRef.current = runner.phase;
  }, [runner.phase]);

  const [copied, setCopied] = useState(false);
  const handleCopy = () => {
    const text = runner.output.map((line) => line.text).join('');
    copyToClipboard(text);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  // Folder has a track-selection picker (convert-video style) → the "keep every track" escape hatch applies.
  const hasSelectablePickers = Object.values(inputDefs ?? {}).some(
    (d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks',
  );

  // Non-inline tasks become jobs — check if one is already running so we can offer Queue.
  useEffect(() => {
    if (alwaysInline) return;
    client
      .get<Array<unknown>>('/jobs?live=1')
      .then((live) => setJobRunning(live.length > 0))
      .catch(() => {});
  }, [alwaysInline]);

  // Which per-group pickers this task declares, and whether the current selection is real work.
  const has = pickerKinds(inputDefs);
  const perGroupHasWork =
    perGroupTracks &&
    entryType === 'directory' &&
    buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0;

  // Collect the final input map (per-group config / include list / keep-all overrides all fold in here).
  const buildAllInputs = (): Record<string, string> => {
    if (perGroupTracks && entryType === 'directory') {
      const config = buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles);
      return { ...formValues, ...(config.length ? { group_config: JSON.stringify(config) } : {}), ...autoInputs };
    }
    const overrides: Record<string, string> = {};
    if (keepAll && inputDefs) {
      for (const [key, def] of Object.entries(inputDefs)) {
        if (def.type === 'audio_tracks' || def.type === 'subtitle_tracks') overrides[key] = '';
      }
    }
    const inc = keepAll ? (selectedNames && selectedNames.length > 1 ? selectedNames.join('\n') : '') : includeFiles;
    return { ...formValues, ...overrides, ...(inc ? { include: inc } : {}), ...autoInputs };
  };

  // Inline: run ephemerally in the modal. Job: create via REST and jump to the jobs view.
  const runInline = () => runner.run(taskDirName, buildAllInputs(), cwd);
  const submitJob = async (action: 'start' | 'queue') => {
    try {
      const { jobId } = await client.post<{ jobId: string }>('/jobs', {
        taskDirName,
        inputs: buildAllInputs(),
        cwd,
        action,
      });
      setCreated({ jobId, action });
    } catch {
      /* stays on the modal so the user can retry */
    }
  };

  if (created) {
    return (
      <div className="flex-1 flex flex-col items-center justify-center gap-4 p-8 text-center">
        <CircleCheck className="h-10 w-10 text-duck-teal" />
        <div>
          <div className="text-base font-medium text-duck-dark dark:text-foreground">
            {created.action === 'queue' ? 'Job queued' : 'Job started'}
          </div>
          <div className="text-sm text-duck-dark/50 dark:text-foreground/50 mt-1">
            {created.action === 'queue'
              ? 'It will run when the current job finishes.'
              : 'It’s running in the background.'}
          </div>
        </div>
        <div className="flex items-center gap-3">
          <button
            onClick={() => {
              onClose();
              navigate(created.action === 'queue' ? '/jobs' : `/jobs/${created.jobId}`);
            }}
            className="flex items-center gap-2 px-5 py-2 rounded-lg bg-duck-teal text-white text-sm font-medium hover:bg-duck-teal/90 cursor-pointer"
          >
            <ExternalLink className="h-4 w-4" /> {created.action === 'queue' ? 'View queue' : 'View job'}
          </button>
          <button
            onClick={onClose}
            className="px-5 py-2 rounded-lg text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer"
          >
            Close
          </button>
        </div>
      </div>
    );
  }

  if (runner.phase === 'ready') {
    return (
      <div className="flex-1 flex flex-col min-h-0">
        {selectedNames && selectedNames.length > 1 && (
          <div className="shrink-0 px-5 py-2 border-b border-duck-dark/10 text-sm text-duck-dark/70 dark:text-foreground/70">
            Running on <span className="font-medium">{selectedNames.length}</span> selected items
          </div>
        )}
        <div className="flex-1 min-h-0 overflow-y-auto">
          {perGroupTracks && entryType === 'directory' ? (
            <PerGroupTrackConfig
              groups={allGroups}
              has={has}
              sel={groupSel}
              onChange={(gi, patch) => setGroupSel((prev) => prev.map((s, i) => (i === gi ? { ...s, ...patch } : s)))}
              probing={probing}
            />
          ) : (
            <>
              {entryType === 'directory' && folder && !probing && (
                <FolderSummary
                  folder={folder}
                  keepAll={keepAll}
                  onKeepAllChange={
                    folderKeepAll && hasSelectablePickers && folder.skipped.length > 0 ? setKeepAll : undefined
                  }
                />
              )}
              {taskDirName === 'get-lyrics' && audioMeta && (
                <AudioMetaPanel meta={audioMeta} fileName={filePath?.split('/').pop() ?? ''} />
              )}
              {inputDefs && (
                <TaskInputForm
                  inputDefs={inputDefs}
                  values={formValues}
                  onChange={handleInputChange}
                  autoFilledKeys={autoFilledKeys}
                  audioTracks={audioTracks}
                  subtitleTracks={subtitleTracks}
                  probing={probing}
                  entryType={entryType}
                  hideTrackPickers={keepAll}
                />
              )}
            </>
          )}
        </div>
        <div className="shrink-0 flex items-center justify-center gap-3 border-t border-duck-dark/10 py-4">
          {(() => {
            const noWork = perGroupTracks && entryType === 'directory' && !perGroupHasWork;
            const base =
              'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer';
            const jobDisabled = !inputDefs || probing || noWork;
            const runHere = (
              <button
                onClick={runInline}
                disabled={!runner.isConnected || jobDisabled}
                className={`${base} ${
                  canChooseRunMode
                    ? 'bg-duck-dark/10 dark:bg-foreground/10 text-duck-dark dark:text-foreground hover:bg-duck-dark/15 dark:hover:bg-foreground/15'
                    : 'bg-duck-teal text-white hover:bg-duck-teal/90'
                }`}
              >
                <Play className="h-4 w-4" /> {canChooseRunMode ? 'Run here' : 'Run'}
              </button>
            );
            if (alwaysInline) return runHere;
            return (
              <>
                {canChooseRunMode && runHere}
                {jobRunning && (
                  <button
                    onClick={() => submitJob('queue')}
                    disabled={jobDisabled}
                    className={`${base} bg-duck-dark/10 dark:bg-foreground/10 text-duck-dark dark:text-foreground hover:bg-duck-dark/15 dark:hover:bg-foreground/15`}
                  >
                    Queue
                  </button>
                )}
                <button
                  onClick={() => submitJob('start')}
                  disabled={jobDisabled}
                  className={`${base} text-white ${jobRunning ? 'bg-red-500 hover:bg-red-500/90' : 'bg-duck-teal hover:bg-duck-teal/90'}`}
                >
                  <Play className="h-4 w-4" />{' '}
                  {canChooseRunMode ? (jobRunning ? 'Run now as job' : 'Run as job') : jobRunning ? 'Run now' : 'Run'}
                </button>
              </>
            );
          })()}
        </div>
      </div>
    );
  }

  return (
    <div className="flex-1 flex flex-col min-h-0">
      <div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
        <div className="relative group">
          <button
            onClick={handleCopy}
            className="absolute top-2 right-2 p-1.5 rounded-md bg-white/10 text-white/40 hover:text-white hover:bg-white/20 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer z-10"
            title="Copy output"
          >
            {copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
          </button>
          <pre className="text-xs font-mono whitespace-pre-wrap break-words bg-[#0d1117] text-gray-200 rounded-lg p-4 pr-10">
            <code>
              {runner.output.map((line, i) => (
                <span
                  key={i}
                  // The panel is a fixed dark terminal, so stdout must inherit the <pre>'s light
                  // colour. `text-foreground` follows the app theme and rendered black-on-black in
                  // light mode.
                  className={
                    line.stream === 'stderr'
                      ? 'text-red-400'
                      : line.stream === 'system'
                        ? 'text-duck-teal/70'
                        : undefined
                  }
                >
                  {line.text}
                </span>
              ))}
            </code>
          </pre>
        </div>
        <div ref={bottomRef} />
      </div>
      <div className="shrink-0 flex justify-center py-3 border-t border-duck-dark/10">
        {runner.phase === 'running' ? (
          <button
            onClick={runner.stop}
            className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
          >
            <Square className="h-3.5 w-3.5" />
            Stop
          </button>
        ) : runner.exitCode !== 0 ? (
          <span className="flex items-center gap-2 text-sm text-red-500 font-medium">
            <CircleX className="h-4 w-4" />
            Task failed (exit {runner.exitCode})
          </span>
        ) : (
          <span className="flex items-center gap-2 text-sm text-green-500 font-medium">
            <CircleCheck className="h-4 w-4" />
            Task complete
          </span>
        )}
      </div>
    </div>
  );
};

// ── Pipeline-mode runner ──

type PipelineRunnerProps = {
  taskDirName: string;
  context: Record<string, string>;
  cwd?: string;
  onClose: () => void;
};

const PipelineRunner = ({ taskDirName, context, cwd, onClose }: PipelineRunnerProps) => {
  const pipeline = usePipelineRunner();
  const navigate = useNavigate();
  const client = useClient();
  const bottomRef = useRef<HTMLDivElement | null>(null);
  const [inputDefs, setInputDefs] = useState<Record<string, TaskInputDef> | null>(null);
  const [formValues, setFormValues] = useState<Record<string, string>>({});
  const [hasConcurrentSteps, setHasConcurrentSteps] = useState(false);
  const [concurrency, setConcurrency] = useState('1');
  const [pipelineSteps, setPipelineSteps] = useState<Array<{ task: string; foreach?: string }>>([]);
  const [startAt, setStartAt] = useState(0);
  const { settings } = useSettings();
  const availableModels = useUserVisibleModels();
  const [selectedModel, setSelectedModel] = useState(settings.tasks.defaultModel ?? 'claude-haiku-4-5-20251001');

  // Fetch task detail for inputs + check for concurrent steps
  useEffect(() => {
    client
      .get<{
        inputs?: Record<string, TaskInputDef>;
        config?: { steps?: Array<{ task: string; foreach?: string; concurrency?: string | boolean }> };
      }>(`/tasks/${taskDirName}`)
      .then((task) => {
        const defs: Record<string, TaskInputDef> = task.inputs ?? {};
        setInputDefs(defs);
        const initial: Record<string, string> = {};
        for (const [key, def] of Object.entries(defs)) {
          if (def.autofill && context[def.autofill]) initial[key] = context[def.autofill]!;
          else if (def.default !== undefined) initial[key] = def.default;
        }
        setFormValues(initial);

        const steps = task.config?.steps ?? [];
        setPipelineSteps(steps.map((s) => ({ task: s.task, foreach: s.foreach })));
        setHasConcurrentSteps(steps.some((s: { concurrency?: string | boolean }) => s.concurrency));
      });
  }, [taskDirName]);

  const handleInputChange = (key: string, value: string) => {
    setFormValues((prev) => ({ ...prev, [key]: value }));
  };

  // Auto-scroll
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [pipeline.messages, pipeline.streamingText, pipeline.currentStep, pipeline.parallelStep]);

  // Ding on completion
  const prevPhaseRef = useRef(pipeline.phase);
  useEffect(() => {
    if (prevPhaseRef.current === 'running' && pipeline.phase === 'done') {
      playDing();
    }
    prevPhaseRef.current = pipeline.phase;
  }, [pipeline.phase]);

  const handleRun = () => {
    const allInputs = { ...formValues };
    if (hasConcurrentSteps && parseInt(concurrency, 10) > 1) {
      allInputs._concurrency = concurrency;
    }
    pipeline.run(taskDirName, allInputs, cwd, selectedModel, startAt > 0 ? startAt : undefined);
  };

  const hasConfigurableInputs = inputDefs && Object.keys(inputDefs).length > 0;

  if (pipeline.phase === 'ready') {
    return (
      <div className="flex-1 flex flex-col">
        {hasConfigurableInputs && (
          <TaskInputForm
            inputDefs={inputDefs}
            values={formValues}
            onChange={handleInputChange}
            autoFilledKeys={new Set()}
          />
        )}
        {hasConcurrentSteps && (
          <div className="px-5 py-3 border-b border-duck-dark/10 flex items-center justify-between gap-4">
            <div className="min-w-0">
              <span className="text-sm font-medium text-duck-dark dark:text-foreground">Concurrency</span>
              <span className="text-xs text-duck-dark/50 dark:text-foreground/50 ml-2">parallel runners per step</span>
            </div>
            <input
              type="number"
              min={1}
              max={20}
              value={concurrency}
              onChange={(e) => setConcurrency(e.target.value)}
              className="w-16 px-2 py-1 text-sm text-center rounded-lg border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal"
            />
          </div>
        )}
        <div className="px-5 py-3 border-b border-duck-dark/10 flex items-center justify-between gap-4">
          <span className="text-sm font-medium text-duck-dark dark:text-foreground">Model</span>
          <select
            value={selectedModel}
            onChange={(ev) => setSelectedModel(ev.target.value)}
            className="px-2 py-1 text-sm rounded-lg border border-duck-dark/15 dark:border-foreground/15 bg-background focus:outline-none focus:ring-1 focus:ring-duck-teal"
          >
            {availableModels.map((m) => (
              <option key={`${m.provider}:${m.id}`} value={m.id}>
                {m.name}
              </option>
            ))}
          </select>
        </div>
        {pipelineSteps.length > 0 && (
          <div className="px-5 py-3 border-b border-duck-dark/10">
            <span className="text-sm font-medium text-duck-dark dark:text-foreground mb-2 block">Steps</span>
            <div className="flex flex-col gap-0.5">
              {pipelineSteps.map((step, i) => (
                <button
                  key={i}
                  onClick={() => setStartAt(i)}
                  className={`text-left text-xs px-2.5 py-1.5 rounded-md transition-colors cursor-pointer flex items-center gap-2 ${
                    i < startAt
                      ? 'text-duck-dark/30 dark:text-foreground/30 line-through'
                      : i === startAt
                        ? 'bg-duck-teal/10 text-duck-teal font-medium'
                        : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5'
                  }`}
                >
                  <span className="w-4 text-center font-mono text-[10px]">{i + 1}</span>
                  <span>{step.task}</span>
                  {step.foreach && <span className="text-duck-dark/40 dark:text-foreground/40">(foreach)</span>}
                  {i === startAt && i > 0 && <span className="ml-auto text-[10px] text-duck-teal/70">start here</span>}
                </button>
              ))}
            </div>
          </div>
        )}
        <div className="flex-1 flex items-center justify-center">
          <button
            onClick={handleRun}
            disabled={!pipeline.isConnected || !inputDefs}
            className="flex items-center gap-2 px-6 py-2.5 rounded-lg bg-duck-teal text-white font-medium text-sm hover:bg-duck-teal/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
          >
            <Play className="h-4 w-4" />
            {startAt > 0 ? `Run from step ${startAt + 1}` : 'Run Pipeline'}
          </button>
        </div>
      </div>
    );
  }

  const formatElapsed = (s: number) => {
    const m = Math.floor(s / 60);
    const sec = s % 60;
    return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
  };

  const rc = pipeline.runningCost;
  const totalTokens = rc.inputTokens + rc.outputTokens;
  const ps = pipeline.parallelStep;

  // Parallel progress counts
  const pDone = ps?.iterations.filter((it) => it.status === 'complete').length ?? 0;
  const pRunning = ps?.iterations.filter((it) => it.status === 'running').length ?? 0;
  const pError = ps?.iterations.filter((it) => it.status === 'error').length ?? 0;
  const pTotal = ps?.iterations.length ?? 0;

  return (
    <div className="flex-1 flex flex-col min-h-0">
      {/* Step progress header — sequential */}
      {pipeline.currentStep && (
        <div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-duck-dark/3">
          <div className="flex items-center gap-2 text-sm">
            <span className="font-medium text-duck-dark">{pipeline.currentStep.taskName}</span>
            {pipeline.currentStep.iteration && (
              <span className="text-duck-dark/50 text-xs">
                ({pipeline.currentStep.iteration.current}/{pipeline.currentStep.iteration.total})
                <span className="ml-1 font-mono">{pipeline.currentStep.iteration.label}</span>
              </span>
            )}
            {pipeline.currentStep.status === 'running' && (
              <span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
            )}
            {pipeline.currentStep.status === 'complete' && <span className="ml-auto text-xs text-green-600">done</span>}
          </div>
        </div>
      )}

      {/* Step progress header — parallel */}
      {ps && (
        <div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-duck-dark/3">
          <div className="flex items-center gap-2 text-sm">
            <span className="font-medium text-duck-dark">{ps.taskName}</span>
            <span className="text-duck-dark/50 text-xs">
              {pDone}/{pTotal} done
              {pRunning > 0 && <span className="ml-1">· {pRunning} running</span>}
              {pError > 0 && <span className="ml-1 text-red-500">· {pError} failed</span>}
            </span>
            {pDone + pError < pTotal && (
              <span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
            )}
            {pDone + pError === pTotal && pTotal > 0 && <span className="ml-auto text-xs text-green-600">done</span>}
          </div>
        </div>
      )}

      {/* Running stats */}
      {pipeline.phase === 'running' && (
        <div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/5 flex items-center gap-4 text-xs text-duck-dark/40 font-mono tabular-nums">
          <span>{formatElapsed(pipeline.elapsed)}</span>
          {totalTokens > 0 && <span>{totalTokens.toLocaleString()} tok</span>}
          {rc.totalUSD > 0 && <span>${rc.totalUSD.toFixed(3)}</span>}
          {pipeline.waitingStatus && (
            <span className="ml-auto flex items-center gap-1.5 text-amber-500">
              <Loader2 className="h-3 w-3 animate-spin" />
              Waiting for model ({formatElapsed(pipeline.waitingStatus.elapsed)})
            </span>
          )}
        </div>
      )}

      {/* Content area */}
      <div className="flex-1 min-h-0 overflow-y-auto">
        {/* Parallel iteration grid */}
        {ps && (
          <div className="px-4 py-3 space-y-1">
            {ps.iterations.map((it) => (
              <div key={it.label} className="flex items-center gap-2 py-1 px-2 rounded text-sm">
                {it.status === 'pending' && (
                  <span className="h-4 w-4 rounded-full border border-duck-dark/20 shrink-0" />
                )}
                {it.status === 'running' && <Loader2 className="h-4 w-4 text-amber-500 animate-spin shrink-0" />}
                {it.status === 'complete' && <CircleCheck className="h-4 w-4 text-green-500 shrink-0" />}
                {it.status === 'error' && <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />}
                <span
                  className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-red-500' : 'text-duck-dark/60'}`}
                >
                  {it.label}
                </span>
                {it.cost && (
                  <span className="ml-auto text-xs text-duck-dark/30 font-mono tabular-nums shrink-0">
                    ${it.cost.totalUSD.toFixed(3)}
                  </span>
                )}
                {it.error && (
                  <span className="ml-auto text-xs text-red-400 truncate max-w-[200px]" title={it.error}>
                    {it.error}
                  </span>
                )}
              </div>
            ))}
          </div>
        )}

        {/* Sequential messages */}
        {pipeline.messages.map((msg, i) => (
          <div key={i} className="px-4 py-1.5">
            <MessageBubble message={msg} onAnswer={() => {}} />
          </div>
        ))}
        {pipeline.streamingText && (
          <div className="px-4 py-1.5">
            <StreamingBubble text={pipeline.streamingText} />
          </div>
        )}
        <div ref={bottomRef} />
      </div>

      {/* Footer */}
      <div className="shrink-0 flex flex-col items-center gap-1 py-3 border-t border-duck-dark/10">
        {pipeline.phase === 'running' ? (
          <button
            onClick={pipeline.stop}
            className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-red-500/10 text-red-600 text-sm font-medium hover:bg-red-500/20 transition-colors cursor-pointer"
          >
            <Square className="h-3.5 w-3.5" />
            Stop Pipeline
          </button>
        ) : pipeline.hasError ? (
          <span className="flex items-center gap-2 text-sm text-red-500 font-medium">
            <CircleX className="h-4 w-4" />
            Pipeline failed
          </span>
        ) : (
          <span className="flex items-center gap-2 text-sm text-green-500 font-medium">
            <CircleCheck className="h-4 w-4" />
            Pipeline complete
          </span>
        )}
        {pipeline.totalCost && (
          <span className="text-xs text-duck-dark/40 font-mono tabular-nums">
            {formatElapsed(pipeline.elapsed)} ·{' '}
            {(pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens).toLocaleString()} tokens · $
            {pipeline.totalCost.totalUSD.toFixed(3)}
          </span>
        )}
        {pipeline.skippedItems.length > 0 && (
          <span className="text-xs text-duck-dark/40">
            {pipeline.skippedItems.length} skipped ({pipeline.skippedItems.map((s) => s.label).join(', ')})
          </span>
        )}
        {pipeline.jobId && (
          <button
            onClick={() => {
              onClose();
              navigate(`/jobs/${pipeline.jobId}`);
            }}
            className="flex items-center gap-1.5 text-xs text-duck-teal hover:text-duck-teal/80 transition-colors mt-1 cursor-pointer"
          >
            <ExternalLink className="h-3 w-3" />
            View in Jobs
          </button>
        )}
      </div>
    </div>
  );
};

type TaskRunnerModalProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  task: TaskSummary;
  entryName?: string;
  entryFullPath?: string;
  entryType?: 'file' | 'directory';
  cwd?: { root?: string; path: string };
  promptOverride?: string;
  description?: string;
  selectedNames?: string[];
  folderFullPath?: string;
};

export const TaskRunnerModal = ({
  open,
  onOpenChange,
  task,
  entryName,
  entryFullPath,
  entryType,
  cwd = { path: '' },
  promptOverride,
  description,
  selectedNames,
  folderFullPath,
}: TaskRunnerModalProps) => {
  const navigate = useNavigate();
  const { settings } = useSettings();
  const taskSettings = settings.tasks;
  const entryRef = entryFullPath ?? entryName;
  const isScript = task.mode === 'script';
  const isPipeline = task.mode === 'pipeline';

  // Multi-selection: run a script task on the whole current folder, scoped to the selected files.
  const multi = isScript && (selectedNames?.length ?? 0) > 1;
  const effectiveEntryType = multi ? 'directory' : entryType;

  // Agentic mode prompt (fallback if task has no body)
  const defaultInput =
    promptOverride ??
    (entryRef && entryType
      ? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
      : `Execute the task "${task.name}" (${task.dirName})`);
  const taskInfo: TaskInfo = {
    taskName: task.name,
    taskDirName: task.dirName,
    entryName: entryName ?? '',
    entryType: entryType ?? 'file',
  };

  // Context values for autofill
  // Path the agent sees, relative to the owner's home.
  const homePrefix = '~';
  const entryRelPath =
    entryName && cwd.path
      ? `${homePrefix}/${cwd.path}/${entryName}`
      : entryName
        ? `${homePrefix}/${entryName}`
        : undefined;
  const autofillContext: Record<string, string> = {};
  if (entryName) autofillContext.entry_name = entryName;
  if (entryRelPath) autofillContext.entry_path = entryRelPath;

  // Script mode: auto-filled inputs from context (e.g. file_path from file browser)
  const autoInputs: Record<string, string> = {};
  if (multi && folderFullPath) autoInputs.file_path = folderFullPath;
  else if (entryFullPath) autoInputs.file_path = entryFullPath;

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogPortal>
        <DialogOverlay className="z-[700] bg-black/60 backdrop-blur-sm" />
        <DialogPrimitive.Content
          onOpenAutoFocus={(ev) => ev.preventDefault()}
          className="fixed left-[50%] top-[50%] z-[700] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl w-[90vw] max-w-3xl h-[80vh] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
          style={cardStyle()}
        >
          {/* Header */}
          <div className="shrink-0 flex flex-col border-b border-duck-dark/10 bg-background/60">
            <div className="flex items-center gap-3 px-5 py-3">
              <div className="flex-1 min-w-0">
                <span className="text-sm font-semibold text-duck-dark truncate block">{task.name}</span>
                <span className="text-xs text-duck-dark/50 truncate block">{entryName}</span>
              </div>
              <DialogPrimitive.Close className="p-1.5 rounded-md text-duck-dark/40 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer">
                <X className="h-4 w-4" />
              </DialogPrimitive.Close>
            </div>
            {description && (
              <p className="px-5 pb-3 text-xs text-duck-dark/50 dark:text-foreground/50 -mt-1">{description}</p>
            )}
          </div>

          {/* Task Runner — branch on mode */}
          {isPipeline ? (
            <PipelineRunner
              key="pipeline"
              onClose={() => onOpenChange(false)}
              taskDirName={task.dirName}
              context={autofillContext}
              cwd={
                entryType === 'directory' && entryName
                  ? cwd.path
                    ? `${cwd.path}/${entryName}`
                    : entryName
                  : cwd.path || undefined
              }
            />
          ) : isScript ? (
            <ScriptRunner
              key="script"
              taskDirName={task.dirName}
              autoInputs={autoInputs}
              context={autofillContext}
              cwd={
                entryType === 'directory' && entryName
                  ? cwd.path
                    ? `${cwd.path}/${entryName}`
                    : entryName
                  : cwd.path || undefined
              }
              entryType={effectiveEntryType}
              filePath={
                multi
                  ? cwd.path || undefined
                  : entryName
                    ? cwd.path
                      ? `${cwd.path}/${entryName}`
                      : entryName
                    : undefined
              }
              selectedNames={multi ? selectedNames : undefined}
              onClose={() => onOpenChange(false)}
            />
          ) : (
            <AgenticTaskRunner
              key="agentic"
              taskDirName={task.dirName}
              defaultInput={defaultInput}
              cwd={
                entryType === 'directory' && entryName
                  ? { ...cwd, path: cwd.path ? `${cwd.path}/${entryName}` : entryName }
                  : cwd
              }
              initialModel={null}
              taskInfo={taskInfo}
              context={autofillContext}
            />
          )}
        </DialogPrimitive.Content>
      </DialogPortal>
    </Dialog>
  );
};
