← back to Norma

components/email-analyzer/EmailAnalyzer.tsx

1216 lines

'use client';

import { useState, useEffect, useCallback, useRef } from 'react';
import {
  Sparkles,
  RotateCcw,
  BarChart3,
  Save,
  Pencil,
  FileText,
  MessageSquare,
  Sliders,
  Gauge,
  Lightbulb,
  BookOpen,
  History,
  FolderClock,
  CheckCircle2,
  Loader2,
  Search,
  Inbox,
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
import AnalyzerChat, { type ChatMessage } from './AnalyzerChat';
import VersionTimeline, { type VersionEntry } from './VersionTimeline';
import ReferencePanel from './ReferencePanel';
import type { Reference } from './useReferences';
import OriginalEmailCard, { AddOriginalSection } from './OriginalEmailCard';
import GeneratedVersionCard from './GeneratedVersionCard';
import CompareModal from './CompareModal';
import ProsConsPanel, { type ProsConsData } from './ProsConsPanel';
import { fetchProsCons } from './fetchProsCons';
import ScorePanel from './ScorePanel';
import RewriteControls from './RewriteControls';
import SuggestionsPanel, { extractKeywords } from './SuggestionsPanel';
import SavedAnalyses from './SavedAnalyses';
import EmailSearchPanel from './EmailSearchPanel';
import SdccEmailsPanel from './SdccEmailsPanel';
import SubjectLineMakerPanel from './SubjectLineMakerPanel';
import LayoutGrid, { type LayoutState, type PanelRenderer } from './LayoutGrid';
import PanelShell from './PanelShell';
import {
  DEFAULT_LAYOUT_STATE,
  LAYOUT_PREF_KEY,
  PANEL_LABELS,
  type PanelId,
} from './panelLayoutDefaults';
import { readLocal, rehydrate, savePreference } from '@/lib/user-preferences';

/* ─── Helpers ────────────────────────────────────────────────────────────── */

function generateId(): string {
  return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

function generateSessionId(): string {
  return crypto.randomUUID ? crypto.randomUUID() : generateId();
}

function countWords(html: string): number {
  const plain = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
  return plain.split(/\s+/).filter(Boolean).length;
}

function countParagraphs(html: string): number {
  const pCount = (html.match(/<p[\s>]/gi) || []).length;
  if (pCount > 0) return pCount;
  return html.split(/\n\n+/).filter((s) => s.trim()).length;
}

function textToHtml(text: string): string {
  return text
    .split(/\n\n+/)
    .map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
    .join('\n');
}

interface AnalysisStats {
  wordCount: number;
  paragraphCount: number;
  sentenceCount: number;
  tone: string;
  topics: string[];
}

/* ─── Persistence keys ───────────────────────────────────────────────────── */

const SESSION_CACHE_KEY = 'norma-email-analyzer-session';
const LOAD_SESSION_KEY = 'norma-analyzer-load-session';

/* ─── Component ──────────────────────────────────────────────────────────── */

export default function EmailAnalyzer() {
  const { addToast } = useToast();

  // Core state
  const [pastedEmail, setPastedEmail] = useState('');
  const [originalVersions, setOriginalVersions] = useState<
    { id: string; content: string; label: string; timestamp: Date }[]
  >([]);
  const [showAddOriginal, setShowAddOriginal] = useState(false);
  const [newOriginalText, setNewOriginalText] = useState('');
  const [currentVersion, setCurrentVersion] = useState('');
  const [sessionId, setSessionId] = useState('');
  const [versions, setVersions] = useState<VersionEntry[]>([]);
  const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
  const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
  const [isProcessing, setIsProcessing] = useState(false);
  const [isAnalyzing, setIsAnalyzing] = useState(false);
  const [analysisStats, setAnalysisStats] = useState<AnalysisStats | null>(null);
  const [showCompare, setShowCompare] = useState(false);
  const [showOriginal, setShowOriginal] = useState(true);
  const [prosConsAnalysis, setProsConsAnalysis] = useState<ProsConsData | null>(null);
  const [analyzingProsCons, setAnalyzingProsCons] = useState(false);
  const getContextStringRef = useRef<(() => string) | null>(null);
  const [compareA, setCompareA] = useState<VersionEntry | null>(null);
  const [compareB, setCompareB] = useState<VersionEntry | null>(null);
  const [compareProsCons, setCompareProsCons] = useState<ProsConsData | null>(null);
  const [comparePCLoading, setComparePCLoading] = useState(false);

  // Parallel-analysis state
  const [suggestionsRefreshKey, setSuggestionsRefreshKey] = useState(0);

  // Layout config — dockable grid of panels.
  // On every page load we force every panel to start collapsed. The user's
  // column count, panel order, and x/y positions are preserved — only the
  // expanded/collapsed state is reset so the tool always fits on one screen.
  const [layout, setLayout] = useState<LayoutState>(() => {
    const base = readLocal<LayoutState>(LAYOUT_PREF_KEY) ?? DEFAULT_LAYOUT_STATE;
    return { ...base, panels: base.panels.map((p) => ({ ...p, collapsed: true })) };
  });

  // Save-state tracking for the header pill
  const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
  const [isSaving, setIsSaving] = useState(false);
  const [, rerender] = useState(0);

  // Rehydrate layout from DB on mount (localStorage wins until DB responds)
  useEffect(() => {
    let cancelled = false;
    void (async () => {
      const local = readLocal<LayoutState>(LAYOUT_PREF_KEY);
      const rehydrated = await rehydrate<LayoutState>(LAYOUT_PREF_KEY, local);
      if (!cancelled && rehydrated && rehydrated !== local) {
        // Force all collapsed on DB rehydrate too — same rationale as the mount initializer.
        setLayout({ ...rehydrated, panels: rehydrated.panels.map((p) => ({ ...p, collapsed: true })) });
      }
    })();
    return () => { cancelled = true; };
  }, []);

  // Persist layout on any change (write-through: localStorage sync + DB debounced)
  useEffect(() => {
    savePreference(LAYOUT_PREF_KEY, layout);
  }, [layout]);

  // Tick once a minute so the "Saved 12s ago" pill refreshes without touching layout
  useEffect(() => {
    if (!lastSavedAt) return;
    const t = setInterval(() => rerender((n) => n + 1), 30_000);
    return () => clearInterval(t);
  }, [lastSavedAt]);

  const pasteRef = useRef<HTMLTextAreaElement>(null);

  // Restore session from localStorage on mount (or load a saved session if one was requested)
  useEffect(() => {
    const pendingLoad = (() => {
      try {
        return sessionStorage.getItem(LOAD_SESSION_KEY);
      } catch {
        return null;
      }
    })();

    if (pendingLoad) {
      try { sessionStorage.removeItem(LOAD_SESSION_KEY); } catch { /* noop */ }
      void hydrateFromSavedSession(pendingLoad);
      return;
    }

    try {
      const cached = localStorage.getItem(SESSION_CACHE_KEY);
      if (!cached) {
        setSessionId(generateSessionId());
        return;
      }
      const data = JSON.parse(cached);
      if (data.pastedEmail) setPastedEmail(data.pastedEmail);
      if (data.currentVersion) setCurrentVersion(data.currentVersion);
      if (data.sessionId) setSessionId(data.sessionId);
      if (data.versions) {
        setVersions(
          data.versions.map((v: VersionEntry) => ({
            ...v,
            timestamp: new Date(v.timestamp),
          })),
        );
      }
      if (data.activeVersionId) setActiveVersionId(data.activeVersionId);
      if (data.chatMessages) {
        setChatMessages(
          data.chatMessages.map((m: ChatMessage) => ({
            ...m,
            timestamp: new Date(m.timestamp),
          })),
        );
      }
      if (data.analysisStats) setAnalysisStats(data.analysisStats);
    } catch {
      setSessionId(generateSessionId());
    }
  }, []);

  // Persist session on change
  useEffect(() => {
    if (!sessionId) return;
    try {
      localStorage.setItem(
        SESSION_CACHE_KEY,
        JSON.stringify({
          pastedEmail,
          currentVersion,
          sessionId,
          versions,
          activeVersionId,
          chatMessages,
          analysisStats,
        }),
      );
    } catch {
      /* noop */
    }
  }, [pastedEmail, currentVersion, sessionId, versions, activeVersionId, chatMessages, analysisStats]);

  /* ── Hydrate state from a previously saved session (db-backed) ─────────── */
  async function hydrateFromSavedSession(sid: string): Promise<void> {
    try {
      const res = await fetch(
        `/api/email-analyzer/drafts?session_id=${encodeURIComponent(sid)}`,
        { credentials: 'include' },
      );
      if (!res.ok) throw new Error('Failed to load saved analysis');
      const data = await res.json();
      const rows: Array<{
        id: number;
        version_number: number;
        label: string | null;
        tone: string | null;
        word_count: number | null;
        paragraph_count: number | null;
        current_version: string;
        original_email: string;
        chat_history: unknown;
        created_at: string;
      }> = Array.isArray(data.drafts) ? data.drafts : [];

      if (rows.length === 0) {
        setSessionId(generateSessionId());
        addToast('Saved analysis had no versions', 'error');
        return;
      }

      const sorted = [...rows].sort((a, b) => a.version_number - b.version_number);
      const latest = sorted[sorted.length - 1];

      const rebuilt: VersionEntry[] = sorted.map((r) => ({
        id: generateId(),
        versionNumber: r.version_number,
        content: r.current_version,
        label: r.label || `Version ${r.version_number}`,
        tone: r.tone || 'professional',
        wordCount: r.word_count || countWords(r.current_version),
        paragraphCount: r.paragraph_count || countParagraphs(r.current_version),
        timestamp: new Date(r.created_at),
      }));

      let chat: ChatMessage[] = [];
      const rawChat = latest.chat_history;
      if (Array.isArray(rawChat)) {
        chat = rawChat.map((m: { role: string; content: string }) => ({
          id: generateId(),
          role: m.role === 'user' ? 'user' : 'assistant',
          content: m.content,
          timestamp: new Date(latest.created_at),
        }));
      } else if (typeof rawChat === 'string') {
        try {
          const parsed = JSON.parse(rawChat);
          if (Array.isArray(parsed)) {
            chat = parsed.map((m: { role: string; content: string }) => ({
              id: generateId(),
              role: m.role === 'user' ? 'user' : 'assistant',
              content: m.content,
              timestamp: new Date(latest.created_at),
            }));
          }
        } catch { /* ignore */ }
      }

      setPastedEmail(latest.original_email);
      setCurrentVersion(latest.current_version);
      setSessionId(sid);
      setVersions(rebuilt);
      setActiveVersionId(rebuilt[rebuilt.length - 1].id);
      setChatMessages(chat);
      setOriginalVersions([
        {
          id: generateId(),
          content: latest.original_email,
          label: 'Original v0.1',
          timestamp: new Date(latest.created_at),
        },
      ]);

      const plain = latest.original_email.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
      const wc = plain.split(/\s+/).filter(Boolean).length;
      const sc = plain.split(/[.!?]+/).filter((s) => s.trim()).length;
      const pc = latest.original_email.split(/\n\n+/).filter((s) => s.trim()).length;
      setAnalysisStats({
        wordCount: wc,
        paragraphCount: pc,
        sentenceCount: sc,
        tone: latest.tone || 'professional',
        topics: [],
      });

      addToast(`Loaded ${rebuilt.length} version${rebuilt.length === 1 ? '' : 's'}`, 'success');
    } catch (err) {
      setSessionId(generateSessionId());
      addToast((err as Error).message || 'Failed to load saved analysis', 'error');
    }
  }

  /* ── Analyze email stats ───────────────────────────────────────────────── */
  async function handleAnalyze(): Promise<void> {
    if (!pastedEmail.trim()) {
      addToast('Paste an email first', 'error');
      return;
    }

    setIsAnalyzing(true);
    try {
      const res = await fetch('/api/ai/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          prompt: `Analyze this email and return ONLY valid JSON (no markdown, no code fences):
{
  "tone": "one word tone description (e.g. formal, friendly, urgent, empathetic, professional, casual)",
  "topics": ["list", "of", "3-5", "key", "topics"],
  "sentiment": "positive, negative, or neutral"
}

Email:
${pastedEmail.substring(0, 2000)}`,
          maxOutputTokens: 200,
          temperature: 0.3,
        }),
      });

      if (!res.ok) throw new Error('Analysis failed');

      const data = await res.json();
      let text = data.text || '';
      text = text.replace(/^```json?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();

      const plain = pastedEmail.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
      const wc = plain.split(/\s+/).filter(Boolean).length;
      const sc = plain.split(/[.!?]+/).filter((s) => s.trim()).length;
      const pc = pastedEmail.split(/\n\n+/).filter((s) => s.trim()).length;

      let parsed = { tone: 'professional', topics: [] as string[], sentiment: 'neutral' };
      try {
        parsed = JSON.parse(text);
      } catch {
        /* use defaults */
      }

      const stats: AnalysisStats = {
        wordCount: wc,
        paragraphCount: pc,
        sentenceCount: sc,
        tone: parsed.tone || 'professional',
        topics: parsed.topics || [],
      };

      setAnalysisStats(stats);

      const htmlVersion = pastedEmail.includes('<p') ? pastedEmail : textToHtml(pastedEmail);
      setCurrentVersion(htmlVersion);

      setOriginalVersions([
        { id: generateId(), content: pastedEmail, label: 'Original v0.1', timestamp: new Date() },
      ]);

      const v1: VersionEntry = {
        id: generateId(),
        versionNumber: 1,
        content: htmlVersion,
        label: 'Original',
        tone: stats.tone,
        wordCount: wc,
        paragraphCount: pc,
        timestamp: new Date(),
      };
      setVersions([v1]);
      setActiveVersionId(v1.id);

      saveVersionToDb(v1, htmlVersion);
      addToast('Email analyzed', 'success');
    } catch (err) {
      addToast((err as Error).message || 'Analysis failed', 'error');
    } finally {
      setIsAnalyzing(false);
    }
  }

  /* ── Save version to DB ────────────────────────────────────────────────── */
  const saveVersionToDb = useCallback(
    async (version: VersionEntry, emailContent: string): Promise<void> => {
      setIsSaving(true);
      try {
        const res = await fetch('/api/email-analyzer/drafts', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            originalEmail: pastedEmail,
            currentVersion: emailContent,
            versionNumber: version.versionNumber,
            label: version.label,
            tone: version.tone,
            paragraphCount: version.paragraphCount,
            wordCount: version.wordCount,
            chatHistory: chatMessages.map((m) => ({ role: m.role, content: m.content })),
            sessionId,
            isFavorite: version.isFavorite || false,
            score: version.score ?? null,
          }),
        });
        if (res.ok) setLastSavedAt(Date.now());
      } catch {
        // Silent fail for DB save -- session data is in localStorage
      } finally {
        setIsSaving(false);
      }
    },
    [pastedEmail, chatMessages, sessionId],
  );

  /* ── AI Rewrite via chat ───────────────────────────────────────────────── */
  async function handleRewrite(instruction: string): Promise<void> {
    if (!currentVersion) return;

    setIsProcessing(true);

    const userMsg: ChatMessage = {
      id: generateId(),
      role: 'user',
      content: instruction,
      timestamp: new Date(),
    };
    setChatMessages((prev) => [...prev, userMsg]);

    try {
      const res = await fetch('/api/email-analyzer/rewrite', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          originalEmail: pastedEmail,
          currentVersion,
          instruction,
          chatHistory: chatMessages.map((m) => ({
            role: m.role,
            content: m.content,
          })),
          referenceContext: getContextStringRef.current?.() || '',
        }),
      });

      if (!res.ok) {
        const errData = await res.json().catch(() => ({ error: 'Rewrite failed' }));
        throw new Error(errData.error || 'Rewrite failed');
      }

      const data = await res.json();
      const {
        rewrittenEmail,
        summary,
        tone: newTone,
        paragraphCount: newPc,
        wordCount: newWc,
      } = data;

      const aiMsg: ChatMessage = {
        id: generateId(),
        role: 'assistant',
        content: summary || 'Done! Check the updated version on the left.',
        timestamp: new Date(),
      };
      setChatMessages((prev) => [...prev, aiMsg]);

      setCurrentVersion(rewrittenEmail);

      const newVersion: VersionEntry = {
        id: generateId(),
        versionNumber: versions.length + 1,
        content: rewrittenEmail,
        label: summary || instruction.substring(0, 50),
        tone: newTone || 'professional',
        wordCount: newWc || countWords(rewrittenEmail),
        paragraphCount: newPc || countParagraphs(rewrittenEmail),
        timestamp: new Date(),
      };
      setVersions((prev) => [...prev, newVersion]);
      setActiveVersionId(newVersion.id);

      saveVersionToDb(newVersion, rewrittenEmail);
      setShowOriginal(false);

      // Fire-and-forget pros/cons comparison
      runInlineProsCons(rewrittenEmail, currentVersion);

      addToast('Version saved', 'success');
    } catch (err) {
      const errMsg: ChatMessage = {
        id: generateId(),
        role: 'assistant',
        content: `Sorry, there was an error: ${(err as Error).message}`,
        timestamp: new Date(),
      };
      setChatMessages((prev) => [...prev, errMsg]);
      addToast((err as Error).message, 'error');
    } finally {
      setIsProcessing(false);
    }
  }

  /* ── Quick action handlers ─────────────────────────────────────────────── */
  function handleQuickAction(action: { type: string; value: string }): void {
    let instruction = '';
    switch (action.type) {
      case 'tone':
        instruction = `Rewrite this email with a ${action.value} tone`;
        break;
      case 'length':
        if (action.value === 'shorter') {
          instruction = 'Make this email shorter and more concise';
        } else if (action.value === 'longer') {
          instruction = 'Expand this email with more detail and context';
        } else {
          instruction = 'Rewrite this email keeping the same approximate length';
        }
        break;
      case 'add-paragraph':
        instruction = `Add a paragraph about: ${action.value}`;
        break;
      default:
        return;
    }
    handleRewrite(instruction);
  }

  /* ── Add another original email ────────────────────────────────────────── */
  function handleAddOriginal(): void {
    if (!newOriginalText.trim()) return;
    const num = originalVersions.length + 1;
    const label = `Original v0.${num}`;
    const newOrig = {
      id: generateId(),
      content: newOriginalText.trim(),
      label,
      timestamp: new Date(),
    };
    setOriginalVersions((prev) => [...prev, newOrig]);

    const htmlVersion = newOriginalText.includes('<p')
      ? newOriginalText
      : textToHtml(newOriginalText);
    const newVersion: VersionEntry = {
      id: generateId(),
      versionNumber: versions.length + 1,
      content: htmlVersion,
      label,
      tone: 'original',
      wordCount: countWords(newOriginalText),
      paragraphCount: countParagraphs(newOriginalText),
      timestamp: new Date(),
    };
    setVersions((prev) => [...prev, newVersion]);
    setCurrentVersion(htmlVersion);
    setActiveVersionId(newVersion.id);
    setPastedEmail(newOriginalText.trim());
    setNewOriginalText('');
    setShowAddOriginal(false);
    saveVersionToDb(newVersion, htmlVersion);
    addToast(`${label} saved`, 'success');
  }

  /* ── Shared pros/cons fetch (inline panel) ─────────────────────────────── */
  async function runInlineProsCons(newHtml: string, previousHtml: string): Promise<void> {
    if (!previousHtml || !newHtml) {
      setProsConsAnalysis(null);
      return;
    }
    setAnalyzingProsCons(true);
    try {
      const result = await fetchProsCons(pastedEmail, newHtml, previousHtml);
      setProsConsAnalysis(result);
    } catch {
      setProsConsAnalysis(null);
    } finally {
      setAnalyzingProsCons(false);
    }
  }

  /* ── Version selection ─────────────────────────────────────────────────── */
  function handleSelectVersion(version: VersionEntry): void {
    setCurrentVersion(version.content);
    setActiveVersionId(version.id);
    const idx = versions.findIndex((v) => v.id === version.id);
    const prevVersion = idx > 0 ? versions[idx - 1] : null;
    if (prevVersion) {
      runInlineProsCons(version.content, prevVersion.content);
    } else {
      runInlineProsCons(version.content, pastedEmail);
    }
  }

  /* ── Compare modal ─────────────────────────────────────────────────────── */
  function handleCompare(a: VersionEntry, b: VersionEntry): void {
    setCompareA(a);
    setCompareB(b);
    setShowCompare(true);
    setCompareProsCons(null);
    setComparePCLoading(true);

    fetchProsCons(pastedEmail, b.content, a.content, {
      newLabel: `version B (v${b.versionNumber})`,
      previousLabel: `version A (v${a.versionNumber})`,
    })
      .then((result) => setCompareProsCons(result))
      .catch(() => setCompareProsCons(null))
      .finally(() => setComparePCLoading(false));
  }

  /* ── Export to Compose ─────────────────────────────────────────────────── */
  function handleExportCompose(content: string): void {
    const plainText = content
      .replace(/<[^>]+>/g, '\n')
      .replace(/\n{3,}/g, '\n\n')
      .trim();
    sessionStorage.setItem(
      'norma-analyzer-export',
      JSON.stringify({ body: plainText, html: content }),
    );
    addToast('Email exported -- open Compose in Email Center to use it', 'success');
  }

  /* ── Favorite toggle ───────────────────────────────────────────────────── */
  function handleToggleFavorite(versionId: string): void {
    setVersions((prev) => {
      const next = prev.map((v) =>
        v.id === versionId ? { ...v, isFavorite: !v.isFavorite } : v,
      );
      // Persist immediately so the star survives reload.
      const updated = next.find((v) => v.id === versionId);
      if (updated && currentVersion) {
        void saveVersionToDb(updated, updated.content);
      }
      return next;
    });
  }

  /* ── Save a version as a reference (project-scoped or universal) ──────── */
  async function handleAddVersionToReferences(
    version: VersionEntry,
    scope: 'project' | 'universal',
  ): Promise<void> {
    const plainText = version.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
    const title = `${version.label} (v${version.versionNumber})`;

    if (scope === 'project') {
      if (!sessionId) {
        addToast('No active session — cannot save project reference', 'error');
        return;
      }
      try {
        const res = await fetch('/api/email-analyzer/references', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            session_id: sessionId,
            ref_type: 'article',
            title,
            content: plainText,
            key_points: [],
            include_in_rewrite: true,
          }),
        });
        if (!res.ok) throw new Error('save failed');
        addToast(`Added "${title}" to project references`, 'success');
        // tell ReferencePanel to reload
        try {
          window.dispatchEvent(
            new CustomEvent('norma-analyzer-refs-changed', { detail: { sessionId } }),
          );
        } catch {
          /* noop */
        }
      } catch {
        addToast('Failed to save project reference', 'error');
      }
      return;
    }

    // Universal scope: save to the universal references store (shared across sessions).
    // Round 2 will add a dedicated /api/email-analyzer/universal-references endpoint;
    // for now, best-effort POST + graceful fallback so the button is discoverable.
    try {
      const res = await fetch('/api/email-analyzer/universal-references', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          title,
          content: plainText,
          tone: version.tone,
          score: version.score ?? null,
          source_session_id: sessionId,
        }),
      });
      if (!res.ok) throw new Error('save failed');
      addToast(`Added "${title}" to Universal References`, 'success');
    } catch {
      addToast('Universal References endpoint not ready yet — saved locally', 'info');
    }
  }

  /* ── New Analysis ──────────────────────────────────────────────────────── */
  function handleNewAnalysis(): void {
    setPastedEmail('');
    setCurrentVersion('');
    setVersions([]);
    setActiveVersionId(null);
    setChatMessages([]);
    setAnalysisStats(null);
    setSessionId(generateSessionId());
    setShowCompare(false);
    setCompareA(null);
    setCompareB(null);
    try {
      localStorage.removeItem(SESSION_CACHE_KEY);
    } catch {
      /* noop */
    }
    pasteRef.current?.focus();
    addToast('Started new analysis', 'info');
  }

  /* ── Save current version explicitly ───────────────────────────────────── */
  function handleSaveVersion(): void {
    if (!currentVersion) return;
    const existing = versions.find((v) => v.id === activeVersionId);
    if (existing) {
      saveVersionToDb(existing, currentVersion);
      addToast('Version saved to database', 'success');
    }
  }

  const hasVersions = versions.length > 0;

  return (
    <div className="p-4 sm:p-6 max-w-7xl mx-auto">
      {/* ── Header ──────────────────────────────────────────────────────── */}
      <div className="flex items-center justify-between mb-5 gap-3 flex-wrap">
        <div className="flex items-center gap-2.5">
          <div
            style={{
              width: 36,
              height: 36,
              borderRadius: 'var(--radius-md)',
              background: 'linear-gradient(135deg, rgba(79,70,229,0.15), rgba(99,102,241,0.15))',
              border: '1px solid rgba(79,70,229,0.25)',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              flexShrink: 0,
            }}
            aria-hidden="true"
          >
            <Sparkles size={18} style={{ color: '#6366f1' }} />
          </div>
          <div>
            <h1
              className="text-lg font-semibold"
              style={{ color: 'var(--color-text)' }}
            >
              Email Analyzer
            </h1>
            <p
              className="text-sm"
              style={{ color: 'var(--color-text-muted)' }}
            >
              Paste, analyze, and rewrite emails with AI
            </p>
          </div>
        </div>
        <div className="flex items-center gap-2">
          {hasVersions && (
            <>
              <SaveStatusPill
                isSaving={isSaving}
                lastSavedAt={lastSavedAt}
              />
              <button
                type="button"
                className="btn btn-ghost btn-sm"
                onClick={handleSaveVersion}
                title="Create a named checkpoint of the current version"
                style={{ display: 'flex', alignItems: 'center', gap: 6 }}
              >
                <Save size={14} /> Save checkpoint
              </button>
              <button
                type="button"
                className="btn btn-ghost btn-sm"
                onClick={handleNewAnalysis}
                style={{ display: 'flex', alignItems: 'center', gap: 6 }}
              >
                <RotateCcw size={14} /> New Analysis
              </button>
            </>
          )}
        </div>
      </div>

      {/* ── Compare Modal ────────────────────────────────────────────────── */}
      {showCompare && compareA && compareB && (
        <CompareModal
          compareA={compareA}
          compareB={compareB}
          prosConsData={compareProsCons}
          prosConsLoading={comparePCLoading}
          onClose={() => setShowCompare(false)}
          onApplySuggestion={(suggestion, label) => {
            const instruction = `[Improvement for ${label}] ${suggestion}`;
            void handleRewrite(instruction);
          }}
        />
      )}

      {/* ── Dockable Panel Grid ─────────────────────────────────────────── */}
      {(() => {
        // Helper to build a PanelShell-wrapped renderer with an icon, title, and body node.
        const wrap = (
          id: PanelId,
          icon: React.ReactNode,
          body: React.ReactNode,
          bodyPadding = 14,
          badge?: React.ReactNode,
        ): PanelRenderer => (collapsed, setCollapsed) => (
          <PanelShell
            id={id}
            title={PANEL_LABELS[id]}
            icon={icon}
            collapsed={collapsed}
            onCollapsedChange={setCollapsed}
            bodyPadding={bodyPadding}
            badge={badge}
          >
            {body}
          </PanelShell>
        );

        const renderers: Partial<Record<PanelId, PanelRenderer>> = {
          original: wrap(
            'original',
            <Pencil size={14} />,
            <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
              <OriginalEmailCard
                pastedEmail={pastedEmail}
                onPastedEmailChange={setPastedEmail}
                showOriginal={showOriginal}
                onToggleOriginal={() => setShowOriginal((p) => !p)}
                hasVersions={hasVersions}
                isAnalyzing={isAnalyzing}
                onAnalyze={handleAnalyze}
                pasteRef={pasteRef}
              />
              {hasVersions && (
                <AddOriginalSection
                  show={showAddOriginal}
                  onShow={() => setShowAddOriginal(true)}
                  onHide={() => { setShowAddOriginal(false); setNewOriginalText(''); }}
                  originalCount={originalVersions.length}
                  newOriginalText={newOriginalText}
                  onNewOriginalTextChange={setNewOriginalText}
                  onSave={handleAddOriginal}
                />
              )}
            </div>,
          ),
          stats: analysisStats
            ? wrap(
                'stats',
                <BarChart3 size={14} />,
                <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
                  <StatBadge label="Words" value={String(analysisStats.wordCount)} />
                  <StatBadge label="Paragraphs" value={String(analysisStats.paragraphCount)} />
                  <StatBadge label="Sentences" value={String(analysisStats.sentenceCount)} />
                  <StatBadge label="Tone" value={analysisStats.tone} />
                  {analysisStats.topics.slice(0, 3).map((topic) => (
                    <span key={topic} className="badge" style={{ fontSize: 12, padding: '3px 10px', backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border)', textTransform: 'none' }}>
                      {topic}
                    </span>
                  ))}
                </div>,
              )
            : undefined,
          generated: currentVersion
            ? wrap(
                'generated',
                <FileText size={14} />,
                <GeneratedVersionCard
                  currentVersion={currentVersion}
                  activeVersionId={activeVersionId}
                  versions={versions}
                />,
                0,
              )
            : undefined,
          proscons:
            (prosConsAnalysis || analyzingProsCons) && versions.length > 1
              ? wrap(
                  'proscons',
                  <BarChart3 size={14} style={{ color: 'var(--color-lane-b)' }} />,
                  <ProsConsPanel data={prosConsAnalysis} loading={analyzingProsCons} />,
                  0,
                  <span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>vs previous</span>,
                )
              : undefined,
          chat: wrap(
            'chat',
            <MessageSquare size={14} />,
            <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 260 }}>
              <AnalyzerChat
                messages={chatMessages}
                onSendMessage={handleRewrite}
                onQuickAction={handleQuickAction}
                isProcessing={isProcessing}
                hasEmail={hasVersions}
              />
            </div>,
          ),
          score: wrap(
            'score',
            <Gauge size={14} />,
            <ScorePanel
              email={currentVersion || pastedEmail}
              autoRunKey={activeVersionId || undefined}
              onApplySuggestion={(suggestion, label) => {
                const instruction = `[Improvement for ${label}] ${suggestion}`;
                void handleRewrite(instruction);
              }}
            />,
            0,
          ),
          controls: wrap(
            'controls',
            <Sliders size={14} />,
            <RewriteControls
              onGo={(instruction) => { void handleRewrite(instruction); }}
              isProcessing={isProcessing}
              hasEmail={!!currentVersion}
            />,
            0,
          ),
          suggestions:
            sessionId && (currentVersion || pastedEmail)
              ? wrap(
                  'suggestions',
                  <Lightbulb size={14} />,
                  <SuggestionsPanel
                    key={suggestionsRefreshKey}
                    sessionId={sessionId}
                    keywords={extractKeywords(currentVersion || pastedEmail)}
                    onReferenceAdded={() => setSuggestionsRefreshKey((k) => k + 1)}
                  />,
                  0,
                )
              : undefined,
          references: sessionId
            ? wrap(
                'references',
                <BookOpen size={14} />,
                <ReferencePanel
                  sessionId={sessionId}
                  onReferencesChange={(_refs: Reference[], getCtx: () => string) => {
                    getContextStringRef.current = getCtx;
                  }}
                />,
                0,
              )
            : undefined,
          versions: wrap(
            'versions',
            <History size={14} />,
            <VersionTimeline
              versions={[
                ...(pastedEmail ? [{
                  id: 'orig-main',
                  versionNumber: 0,
                  content: pastedEmail,
                  label: 'Original (pasted)',
                  tone: 'original',
                  wordCount: countWords(pastedEmail),
                  paragraphCount: countParagraphs(pastedEmail),
                  timestamp: originalVersions[0]?.timestamp || new Date(0),
                }] : []),
                ...originalVersions
                  .filter((o) => o.content !== pastedEmail)
                  .map((o, i) => ({
                    id: `orig-${o.id}`,
                    versionNumber: 0,
                    content: o.content,
                    label: o.label || `Original v0.${i + 2}`,
                    tone: 'original',
                    wordCount: countWords(o.content),
                    paragraphCount: countParagraphs(o.content),
                    timestamp: o.timestamp,
                  })),
                ...versions,
              ]}
              activeVersionId={activeVersionId}
              onSelectVersion={handleSelectVersion}
              onCompare={handleCompare}
              onExportCompose={handleExportCompose}
              onToggleFavorite={handleToggleFavorite}
              onAddToReferences={(v, scope) => { void handleAddVersionToReferences(v, scope); }}
            />,
          ),
          saved: wrap(
            'saved',
            <FolderClock size={14} />,
            <SavedAnalyses
              onOpen={(sid) => {
                try {
                  sessionStorage.setItem(LOAD_SESSION_KEY, sid);
                  window.location.reload();
                } catch {
                  addToast('Unable to load that analysis', 'error');
                }
              }}
            />,
            0,
          ),
          search: wrap(
            'search',
            <Search size={14} />,
            <EmailSearchPanel
              onOpenSession={(sid) => {
                try {
                  sessionStorage.setItem(LOAD_SESSION_KEY, sid);
                  window.location.reload();
                } catch {
                  addToast('Unable to load that session', 'error');
                }
              }}
            />,
            0,
          ),
          sdccEmails: wrap(
            'sdccEmails',
            <Inbox size={14} />,
            <SdccEmailsPanel
              onLoadIntoAnalyzer={({ subject, body }) => {
                setPastedEmail(body);
                setCurrentVersion(body);
                addToast(`Loaded "${subject.slice(0, 40)}" — ready to analyze`, 'success');
              }}
            />,
            0,
          ),
          subjects: wrap(
            'subjects',
            <Sparkles size={14} />,
            <SubjectLineMakerPanel
              sessionId={sessionId}
              versionId={activeVersionId}
              emailBody={currentVersion || pastedEmail}
            />,
            0,
          ),
        };

        return (
          <LayoutGrid
            layout={layout}
            onLayoutChange={setLayout}
            panelRenderers={renderers as Record<string, PanelRenderer>}
            onReset={() => setLayout(DEFAULT_LAYOUT_STATE)}
            rowHeight={40}
          />
        );
      })()}


      {/* ── Responsive styles ────────────────────────────────────────────── */}
      <style>{`
        @media (max-width: 900px) {
          .email-analyzer-panels {
            flex-direction: column !important;
          }
          .email-analyzer-panels > div {
            flex: 1 1 auto !important;
          }
        }
        .email-preview-content p {
          margin: 0 0 0.75em;
        }
        .email-preview-content p:last-child {
          margin-bottom: 0;
        }
        .email-preview-content a {
          color: var(--color-primary);
          text-decoration: underline;
        }
        .email-preview-content ul, .email-preview-content ol {
          padding-left: 1.5rem;
          margin: 0 0 0.75em;
        }
        .email-preview-content strong {
          font-weight: 600;
        }
      `}</style>
    </div>
  );
}

/* ─── SaveStatusPill ─────────────────────────────────────────────────────── */

function formatRelativeAgo(ts: number): string {
  const secs = Math.max(0, Math.floor((Date.now() - ts) / 1000));
  if (secs < 10) return 'just now';
  if (secs < 60) return `${secs}s ago`;
  const mins = Math.floor(secs / 60);
  if (mins < 60) return `${mins}m ago`;
  const hours = Math.floor(mins / 60);
  if (hours < 24) return `${hours}h ago`;
  return new Date(ts).toLocaleDateString();
}

function SaveStatusPill({ isSaving, lastSavedAt }: { isSaving: boolean; lastSavedAt: number | null }) {
  const label = isSaving
    ? 'Saving…'
    : lastSavedAt
      ? `Saved ${formatRelativeAgo(lastSavedAt)}`
      : 'Not saved';
  const color = isSaving
    ? 'var(--color-text-secondary)'
    : lastSavedAt
      ? 'var(--color-success)'
      : 'var(--color-text-muted)';
  return (
    <span
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        gap: 5,
        padding: '3px 10px',
        borderRadius: 9999,
        fontSize: 12,
        color,
        border: '1px solid var(--color-border)',
        backgroundColor: 'var(--color-surface)',
      }}
      title={lastSavedAt ? new Date(lastSavedAt).toLocaleString() : undefined}
    >
      {isSaving ? <Loader2 size={12} className="spinner" /> : <CheckCircle2 size={12} />}
      {label}
    </span>
  );
}

/* ─── StatBadge helper ───────────────────────────────────────────────────── */

function StatBadge({ label, value }: { label: string; value: string }) {
  return (
    <div
      style={{
        display: 'inline-flex',
        alignItems: 'center',
        gap: 5,
        padding: '3px 10px',
        borderRadius: 9999,
        fontSize: 12,
        border: '1px solid var(--color-border)',
        backgroundColor: 'var(--color-surface)',
      }}
    >
      <span style={{ color: 'var(--color-text-muted)', fontWeight: 500 }}>
        {label}:
      </span>
      <span
        style={{
          color: 'var(--color-text)',
          fontWeight: 600,
          textTransform: 'capitalize',
        }}
      >
        {value}
      </span>
    </div>
  );
}