import { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import {
  Volume2,
  Loader2,
  Square,
  Clock,
  Check,
  X,
  CircleSlash,
  Eraser,
  PlugZap,
  RotateCcw,
  FoldVertical,
} from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import type { Tone } from '@/components/Data';
import { toneText } from '@/components/Data';
import { errorText } from 'helpers/error-text';
import type { ChatMessage } from '../types';
import { ToolActivity } from './ToolActivity';
import { QuestionActivity } from './QuestionActivity';
import { getRawUrl } from '../../FileViewer/file-types';
import { useFilesAPI } from '../../../hooks/useFilesAPI';
import { CopyButton } from './CopyButton';
import { CodeBlock } from './CodeBlock';
import { randomId } from 'helpers/random-id';

/** Shared by the settled bubble and the streaming one, so a block gains nothing when the turn ends. */
const MD_COMPONENTS = { pre: CodeBlock };

const sanitizeSchema = {
  ...defaultSchema,
  tagNames: (defaultSchema.tagNames ?? []).filter(
    (tag) => tag !== 'script' && tag !== 'iframe' && tag !== 'object' && tag !== 'embed' && tag !== 'form',
  ),
  attributes: {
    ...defaultSchema.attributes,
    '*': (defaultSchema.attributes?.['*'] ?? []).filter((attr) => typeof attr === 'string' && !attr.startsWith('on')),
  },
};

// Matches absolute image file paths, e.g. /home/user/pic.png or /tmp/photo.jpg
const IMAGE_PATH_RE = /(\/(?:home\/[^/\s]+\/)?[^\s`"'<>\n\r[\]()]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|ico))/gi;

function pathToImageUrl(filePath: string): string {
  // Strip /home/{username} prefix — the file browser root=home serves from the user's home dir
  const relative = filePath.replace(/^\/home\/[^/]+/, '') || '/';
  return getRawUrl(relative, 'home');
}

function injectImages(text: string): string {
  // Split on fenced code blocks and inline code so we don't touch paths inside backticks
  const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`)/g);
  return parts
    .map((part, i) => {
      if (i % 2 === 1) return part; // inside code — leave untouched
      return part.replace(IMAGE_PATH_RE, (match) => {
        const filename = match.split('/').pop() ?? 'image';
        return `![${filename}](${pathToImageUrl(match)})`;
      });
    })
    .join('');
}

const FRONTMATTER_RE = /^<frontmatter>([\s\S]*?)<\/frontmatter>\s*/;

const CollapsibleBlock = ({ label, content }: { label: string; content: string }) => (
  <div className="flex justify-center">
    <details className="max-w-[85%] rounded-2xl bg-muted border border-border px-4 py-2 text-sm">
      <summary className="text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none">
        {label}
      </summary>
      <div className="mt-1.5 text-xs text-muted-foreground whitespace-pre-wrap border-l-2 border-border pl-2 max-h-48 overflow-y-auto">
        {content}
      </div>
    </details>
  </div>
);

type TtsState = 'idle' | 'loading' | 'playing';

const ReadAloudButton = ({ id, text }: { id: string; text: string }) => {
  const [state, setState] = useState<TtsState>('idle');
  const audioRef = useRef<HTMLAudioElement | null>(null);
  const api = useFilesAPI('user-data');

  const handleClick = async () => {
    if (state === 'playing') {
      audioRef.current?.pause();
      audioRef.current = null;
      setState('idle');
      return;
    }
    if (state === 'loading') return;

    setState('loading');
    try {
      const { audioPath, audioRoot } = await api.ttsText(id, text);
      const url = getRawUrl(audioPath, audioRoot);
      const audio = new Audio(url);
      audioRef.current = audio;
      audio.onended = () => {
        audioRef.current = null;
        setState('idle');
      };
      audio.onerror = () => {
        audioRef.current = null;
        setState('idle');
        toast.error('The narration was generated but would not play.');
      };
      await audio.play();
      setState('playing');
    } catch (err) {
      // Synthesis is slow enough that the spinner is the only feedback there is; when it just stops and
      // the icon comes back, the button looks broken rather than failed. Most often this is the TTS
      // service being down, which is worth saying out loud.
      setState('idle');
      toast.error(`Could not read this message aloud: ${errorText(err)}`);
    }
  };

  return (
    <button
      type="button"
      onClick={handleClick}
      className="p-1 rounded text-foreground opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
    >
      {state === 'loading' && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
      {state === 'playing' && <Square className="h-3.5 w-3.5" />}
      {state === 'idle' && <Volume2 className="h-3.5 w-3.5" />}
    </button>
  );
};

type MessageBubbleProps = {
  message: ChatMessage;
  onAnswer?: (text: string) => void;
  onRetry?: (prompt: string) => void;
  /** Set for rows in the turn you are watching; tool rows start expanded and stay that way until it folds. */
  defaultOpen?: boolean;
};

export const MessageBubble = ({ message, onAnswer, onRetry, defaultOpen }: MessageBubbleProps) => {
  switch (message.role) {
    case 'user': {
      const rawText = typeof message.text === 'string' ? message.text : '';
      const match = rawText.match(FRONTMATTER_RE);
      const metadata = match ? match[1]!.trim() : null;
      const text = match ? rawText.slice(match[0]!.length) : rawText;
      return (
        <>
          {metadata && <CollapsibleBlock label="Frontmatter" content={metadata} />}
          <div className="flex justify-end">
            <div className="max-w-[80%] min-w-0 rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-foreground">
              {message.images?.map((img, i) => (
                <img key={i} src={img.dataUrl} alt={img.filename} className="max-w-full max-h-64 rounded-lg mb-2" />
              ))}
              {/* break-words, or a pasted URL or base64 blob pushes the bubble past max-w and out of the pane. */}
              <div className="whitespace-pre-wrap break-words">{text}</div>
            </div>
          </div>
        </>
      );
    }

    case 'system':
      return <CollapsibleBlock label="System prompt" content={message.text} />;

    // The one seam in a merged `/clear` chain. It says what was lost rather than just drawing a line,
    // because the risk of merging is precisely that the conversation looks unbroken to you and is not:
    // ask about something above this and the agent has never seen it.
    case 'divider':
      return (
        <div className="flex items-center gap-3 py-3 text-muted-foreground select-none">
          <div className="h-px flex-1 bg-border" />
          <span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
            <Eraser className="h-3 w-3" />
            Context cleared — nothing above this is in memory
          </span>
          <div className="h-px flex-1 bg-border" />
        </div>
      );

    case 'assistant': {
      const assistantText = typeof message.text === 'string' ? message.text : '';
      if (!assistantText) return null;
      return (
        <div className="flex justify-start group">
          <div className="max-w-[85%]">
            <div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
              <div className="chat-md">
                <ReactMarkdown
                  remarkPlugins={[remarkGfm]}
                  rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
                  components={MD_COMPONENTS}
                >
                  {injectImages(assistantText)}
                </ReactMarkdown>
              </div>
              <div className="flex justify-end -mb-1 -mr-1 gap-0.5">
                <CopyButton text={assistantText} />
                <ReadAloudButton id={message.id ?? randomId()} text={assistantText} />
              </div>
            </div>
          </div>
        </div>
      );
    }

    case 'tool':
      if (message.toolName === 'question' && onAnswer) {
        return <QuestionActivity message={message} onAnswer={onAnswer} />;
      }
      return <ToolActivity message={message} defaultOpen={defaultOpen} />;

    case 'result':
      return (
        <div className="flex justify-center py-1">
          <span className="text-xs tabular-nums text-muted-foreground">
            Done · ${message.cost.totalUSD.toFixed(3)} · {message.cost.inputTokens + message.cost.outputTokens} tokens
          </span>
        </div>
      );

    case 'task':
      return <TaskActivity message={message} />;

    case 'compact':
      return <CompactActivity message={message} />;

    case 'interrupted':
      return (
        <div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
          <div className="h-px flex-1 bg-border" />
          <span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
            <CircleSlash className="h-3 w-3" />
            Interrupted by user
          </span>
          <div className="h-px flex-1 bg-border" />
        </div>
      );

    // The agent process went away mid-turn. Styled as a seam rather than a failure bubble: nothing is
    // broken and nothing is lost except the turn itself, so the row's job is to say what happened and
    // offer the one action that fixes it. The prompt goes back through the normal send path, which lands
    // on a session the fresh agent resumes from the transcript on disk.
    case 'cutoff':
      return (
        <div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
          <div className="h-px flex-1 bg-border" />
          <span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
            <PlugZap className="h-3 w-3" />
            Agent restarted — turn cut off
          </span>
          {message.prompt && onRetry && (
            <button
              type="button"
              onClick={() => onRetry(message.prompt)}
              className="flex cursor-pointer items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] font-medium tracking-wide uppercase transition-colors hover:bg-muted hover:text-foreground"
            >
              <RotateCcw className="h-3 w-3" />
              Retry
            </button>
          )}
          <div className="h-px flex-1 bg-border" />
        </div>
      );

    case 'error':
      return (
        <div className="flex justify-start group">
          <div className="relative max-w-[80%] min-w-0 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive break-words">
            {message.text}
            <div className="flex justify-end -mb-1 -mr-1">
              <CopyButton text={message.text} />
            </div>
          </div>
        </div>
      );
  }
};

/**
 * A background task (run_in_background / Monitor), start to finish, in one row. It renders pending the
 * moment the task starts and resolves in place when the notification lands — which can be long after the
 * turn that started it ended. It used to be two unrelated assistant bubbles a screenful apart, because
 * both sides discarded the taskId that relates them.
 */
const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task' }> }) => {
  const { status, summary, description, taskType } = message;
  const Icon = status === 'completed' ? Check : status === 'failed' ? X : status === 'stopped' ? CircleSlash : Clock;
  const tone: Tone =
    status === 'completed' ? 'success' : status === 'failed' ? 'danger' : status === 'stopped' ? 'neutral' : 'warning';

  return (
    <div className="my-1 flex items-start gap-2 rounded-md bg-muted/50 px-3 py-1.5 text-sm">
      <Icon className={`mt-0.5 h-4 w-4 shrink-0 ${toneText[tone]} ${status ? '' : 'animate-pulse'}`} />
      <div className="min-w-0 flex-1">
        <div className="truncate text-foreground">
          {description || 'Background task'}
          {taskType && <span className="ml-2 text-xs text-muted-foreground">{taskType}</span>}
        </div>
        <div className="text-xs text-muted-foreground">
          {status ? summary || `Task ${status}.` : 'Running in the background…'}
        </div>
      </div>
    </div>
  );
};

const formatDuration = (ms: number): string => {
  const total = Math.round(ms / 1000);
  return total < 60 ? `${total}s` : `${Math.floor(total / 60)}m ${String(total % 60).padStart(2, '0')}s`;
};

const formatTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));

/**
 * Context compaction, start to finish, in one row.
 *
 * This is the only thing the harness does that emits nothing at all while it runs, and it can run for
 * minutes — long enough that the honest read of the silence is "the agent has hung", which costs a server
 * restart and a page reload to discover it hadn't. So the row says what is happening *and* counts, because
 * a spinner with no number cannot distinguish a slow operation from a stuck one.
 *
 * The clock is local and only runs while pending: the start is a hook firing on this connection, so the
 * elapsed time is honest for the window that watched it happen, and the settled row carries the duration
 * the sidecar measured — which is the one a replay in another tab gets.
 */
const CompactActivity = ({ message }: { message: Extract<ChatMessage, { role: 'compact' }> }) => {
  const { done, trigger, preTokens, durationMs } = message;
  const startedAt = useRef(Date.now());
  const [elapsed, setElapsed] = useState(0);

  useEffect(() => {
    if (done) return;
    const timer = setInterval(() => setElapsed(Date.now() - startedAt.current), 1000);
    return () => clearInterval(timer);
  }, [done]);

  const detail = done
    ? [preTokens ? `${formatTokens(preTokens)} tokens summarised` : '', durationMs ? formatDuration(durationMs) : '']
        .filter(Boolean)
        .join(' · ')
    : `${formatDuration(elapsed)} — the agent produces no output while this runs`;

  return (
    <div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
      <div className="h-px flex-1 bg-border" />
      <span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
        <FoldVertical className={`h-3 w-3 ${done ? '' : 'animate-pulse'}`} />
        {done ? `Context compacted${trigger === 'manual' ? '' : ' automatically'}` : 'Compacting the conversation…'}
        {detail && <span className="normal-case opacity-70 tabular-nums">· {detail}</span>}
      </span>
      <div className="h-px flex-1 bg-border" />
    </div>
  );
};

/**
 * The wait between pressing send and the first token — tens of seconds with thinking enabled — used to
 * render nothing at all, because the streaming bubble bailed on empty text. The transcript looked frozen
 * and the only evidence the turn was alive was the send button having become a stop button.
 */
const ThinkingDots = () => (
  <span className="flex items-center gap-1 py-1" role="status" aria-label="Waiting for a response">
    {[0, 150, 300].map((delay) => (
      <span
        key={delay}
        className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60"
        style={{ animationDelay: `${delay}ms` }}
      />
    ))}
  </span>
);

type StreamingBubbleProps = {
  text: string;
};

export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
  return (
    <div className="flex justify-start">
      <div
        className="max-w-[85%] overflow-hidden rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5"
        aria-live="polite"
      >
        {text ? (
          <div className="chat-md">
            <ReactMarkdown
              remarkPlugins={[remarkGfm]}
              rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
              components={MD_COMPONENTS}
            >
              {injectImages(text)}
            </ReactMarkdown>
            <span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />
          </div>
        ) : (
          <ThinkingDots />
        )}
      </div>
    </div>
  );
};
