/* Hello Court v3. Coachmark / walkthrough engine (Story 0.12).

   Drives a step-by-step overlay tour over the v3 demo surfaces. Each step
   declaratively references its target via a data-coach attribute, plus an
   optional screen + matter so a step can navigate the App before measuring.

   Render contract:
     <V3CoachmarkOverlay
       script={V3_WALKTHROUGHS[id]}
       stepIdx={n}
       onPrev|onNext|onSkip|onFinish={...}
       onScreenChange={(screen) => ...}
       onMatterChange={(matter) => ...} />

   The overlay is portaled to document.body so it isn't affected by the
   iPad scene's transform: scale. Targets are measured AFTER two animation
   frames so screen-switches have time to commit before the spotlight
   positions itself.

   Step shape:
     { id, target?, title, body, screen?, matter?, position? }
       - target: string matching [data-coach="..."]; omit for centered modal
       - screen / matter: declarative App nav before measuring
       - position: 'auto' | 'top' | 'bottom' | 'left' | 'right' (default auto)
*/

const V3_WALKTHROUGHS = {
  maria: {
    id: 'maria',
    title: "Maria's path through her case",
    matter: 'maria',
    steps: [
      { id: 'intro', screen: 'home', matter: 'maria',
        title: "Meet Maria",
        body: "Maria rented an apartment in Salt Lake City. When she moved out, her landlord kept her $1,800 deposit and never sent the itemized statement Utah law requires. She can't afford a lawyer. Hello Court helps her file the case herself, the right way." },
      { id: 'home-hero', target: 'home-hero', screen: 'home',
        title: "Her case at a glance",
        body: "Tier 1 means a small-claims case Maria can file pro se. The statute on top is the law that anchors her claim: Utah § 57-17-3, the security-deposit recovery statute." },
      { id: 'home-stage', target: 'home-stage', screen: 'home',
        title: "Where she is in the process",
        body: "Five stages: Intake, Preparation, Filing, Hearing, Resolution. Maria is in Preparation right now. Gathering and organizing evidence so she can file with confidence." },
      { id: 'home-readiness', target: 'home-readiness', screen: 'home',
        title: "Case readiness: 87%",
        body: "Four scores roll up into one number: how complete each legal element is, how well evidence maps to those elements, whether the law is current, and how ready the procedural steps are. Click any row to see what's missing." },
      { id: 'sidebar-theory', target: 'sidebar-theory', screen: 'home',
        title: "Open the legal theory",
        body: "Before evidence, Maria needs the right legal theory. Which law was broken, and what does it ask her to prove? Click Legal theory in the sidebar." },
      { id: 'theory-causes', target: 'theory-causes', screen: 'theory',
        title: "Wrongful retention of security deposit",
        body: "Hello Court lays out the legal theories that fit Maria's facts and shows what each one would require her to prove. She picks. We don't. The selected theory shows the four elements the court will look for." },
      { id: 'sidebar-evidence', target: 'sidebar-evidence', screen: 'theory',
        title: "Now connect her evidence",
        body: "Each piece of evidence has to support a specific element. Hello Court suggests which evidence matches which element; Maria affirms or rejects each connection." },
      { id: 'evidence-elements', target: 'evidence-elements', screen: 'evidence',
        title: "Four elements, all supported",
        body: "Lease, move-out walkthrough photos with EXIF dates, the certified-mail receipt, the 30-day timeline. Every element has at least one affirmed piece of evidence backing it. That's what file-ready means." },
      { id: 'evidence-list', target: 'evidence-list', screen: 'evidence',
        title: "Maria's evidence",
        body: "Seven items uploaded, original capture dates and GPS coordinates preserved on the photos so the court can verify when and where each was taken. Maria can review the metadata before filing." },
      { id: 'sidebar-damages', target: 'sidebar-damages', screen: 'evidence',
        title: "What she's owed",
        body: "Once the elements are proved, the damages calculation is mechanical: actual loss plus what the statute allows. Click Damages." },
      { id: 'damages-math', target: 'damages-math', screen: 'damages',
        title: "Total recovery sought: $3,860",
        body: "$1,800 deposit, $1,800 statutory 2x penalty under Utah § 57-17-3, $200 in documented lost time addressing the dispute, and $60 in court costs. The math is shown line by line so the judge can verify it." },
      { id: 'sidebar-readiness', target: 'sidebar-readiness', screen: 'damages',
        title: "File-ready check",
        body: "Before generating the packet, Hello Court runs a final readiness check. Anything below threshold is flagged with a one-click fix. No surprises in court." },
      { id: 'sidebar-documents', target: 'sidebar-documents', screen: 'readiness',
        title: "The filing packet",
        body: "Ten sections drafted from her affirmed facts. Cover and table of contents up front, then case snapshot, summary, chronological timeline, statement of claim, legal grounds, damages summary, key admissions, and the indexed exhibits. She reads each one and re-affirms before generation." },
      { id: 'documents-list', target: 'documents-list', screen: 'documents',
        title: "Documents drafted from her facts",
        body: "Every line in these documents traces back to something Maria already affirmed. Nothing is invented; nothing is legal advice. The footer makes that clear: Hello Court is not a law firm, the user prepared this with platform tools." },
      { id: 'sidebar-filing', target: 'sidebar-filing', screen: 'documents',
        title: "How she files it",
        body: "Hello Court shows Maria exactly which court, what it costs, how long until the hearing, and how to serve the landlord. Click Filing & service." },
      { id: 'filing-summary', target: 'filing-summary', screen: 'filing',
        title: "Salt Lake County Justice Court",
        body: "The right court for a small-claims security-deposit case in Utah. Filing fee, hearing timeline, and service options laid out side by side. Maria picks how she wants to serve the landlord, and Hello Court walks her through it." },
      { id: 'sidebar-courtroom', target: 'sidebar-courtroom', screen: 'filing',
        title: "Walking into court ready",
        body: "Last step: courtroom prep. Because Utah is sandbox-authorized, Hello Court can give Maria a loose script for her opening, a walk-through of her evidence, and a Q&A prep covering the questions judges ask in deposit cases." },
      { id: 'courtroom-opening', target: 'courtroom-opening', screen: 'courtroom',
        title: "Her opening statement, drafted",
        body: "Sixty seconds. Who Maria is, what happened, what she wants. Specific dates and the statute do the work; outrage and adjectives don't. She practices it; she doesn't recite it." },
      { id: 'guide-handoff', target: 'm-guide-rail', screen: 'home',
        title: "Meet your guide",
        body: "This is where you'll actually do the work. The guide on the right walks you through your real case, plain words, one step at a time. It can also read documents you upload and find ones you already have on your computer." },
      { id: 'finish', screen: 'courtroom',
        title: "That's the path",
        body: "From a kept deposit to a courtroom-ready case, with no lawyer required. Hello Court kept Maria moving forward at every step, told her what to do next, and made the legal mechanics visible. That's the difference between pro se as a last resort and pro se as a real option." },
    ],
  },
  patel: {
    id: 'patel',
    title: "Sarah's guided walkthrough",
    matter: 'patel',
    /* mode: 'interactive' tells the coachmark engine to disable autoplay
       and require user clicks on the spotlit target (or on Next) to advance.
       The animated mouse cursor + chat-guide dialogue coach the user. */
    mode: 'interactive',
    steps: [
      { id: 'intro', screen: 'home', matter: 'patel',
        title: "Meet Sarah",
        body: "Sarah worked as a server at a Manhattan restaurant. For 8 weeks she was scheduled 50 hours each week and paid straight time. On top of that, the floor manager required 30 minutes of off-the-clock prep before every shift. This is the kind of case the platform was built for.",
        chatMessage: "Hi Sarah. I'm your guide. I'm built for this. Walking real people through their own cases, from a messy stack of papers to a clean, court-ready filing. I'll explain the law as we go, catch things you might miss, and draft what you need. You make every call; I do the heavy lifting between calls.\n\nOne honest note: I can't give you legal advice or speak for you in court. That part stays with a licensed attorney if you ever need one. What I can give you is structure, patience, and a path forward.\n\nWe can do this. Click anywhere when you're ready." },
      { id: 'home-hero', target: 'home-hero', screen: 'home',
        title: "Her case at a glance",
        body: "Tier 1 means a small-claims case Sarah can file pro se. The statutes anchoring her claim are NY Lab. Law § 198 and federal FLSA § 207.",
        chatMessage: "Right at the top: NY Labor Law § 198 and federal FLSA § 207. Both require time-and-a-half over 40 hours a week, and NY adds 100% liquidated damages. Those are the statutes anchoring everything else." },
      { id: 'home-stage', target: 'home-stage', screen: 'home',
        title: "Where she is in the process",
        body: "Five stages: Intake, Preparation, Filing, Hearing, Resolution. Sarah is in Preparation.",
        chatMessage: "You're in Preparation, stage 2 of 5. Intake is done. Filing is next once we've finished organizing your evidence." },
      { id: 'home-readiness', target: 'home-readiness', screen: 'home',
        title: "Case readiness: 76%",
        body: "Four scores: element completeness, evidence-element mapping, statutory accuracy, procedural prep. Each gap names exactly what's missing so it closes in minutes, not hours.",
        chatMessage: "Case readiness sits at 76%. The lightest area is procedural prep. We haven't picked your service-of-process method yet. We'll fix that on the Filing screen in a few minutes." },
      { id: 'sidebar-theory', target: 'sidebar-theory', screen: 'home',
        title: "Open the legal theory",
        body: "NY wage law isn't a single statute. It's a stack. Click Legal theory in the sidebar.",
        chatMessage: "Click Legal theory in the sidebar. NY wage law isn't one statute, it's a stack. I want to show you what we have to work with." },
      { id: 'theory-causes', target: 'theory-causes', screen: 'theory',
        title: "Three theories, stacked",
        body: "Unpaid overtime is the primary claim. Recordkeeping violation under § 195(4) is the procedural lever. Anti-retaliation is held in reserve.",
        chatMessage: "Three theories, and we can plead them together. Unpaid overtime is the headline. Recordkeeping violation is the procedural lever. It shifts the burden onto Brooklyn Bites when they can't produce records. Anti-retaliation is held in reserve. Click whichever you want to start with.",
        chatReactions: {
          'unpaid-overtime':         "Good call. Unpaid overtime is the headline claim and the cleanest math. Brooklyn Bites' own timecards prove the 50-hour weeks; FLSA § 207 does the rest.",
          'recordkeeping-violation': "Smart pick. § 195(4) is the procedural lever. Once we plead it, the burden flips: Brooklyn Bites has to produce complete records or your reasonable estimate stands.",
          'retaliation':             "Holding retaliation in reserve is the right move at this stage. If Brooklyn Bites pushes back after you file, § 215 becomes available as a separate claim. For now we focus on the wages.",
        } },
      { id: 'sidebar-evidence', target: 'sidebar-evidence', screen: 'theory',
        title: "Now connect her evidence",
        body: "Wage cases run on records: paystubs, timecards, scheduling texts, contemporaneous notes. Click Evidence.",
        chatMessage: "Now click Evidence. Wage cases run on records: timecards, paystubs, scheduling texts. Let's see what proves what." },
      { id: 'evidence-elements', target: 'evidence-elements', screen: 'evidence',
        title: "Four elements, all supported",
        body: "Non-exempt status, off-the-clock hours, 40-hour overtime threshold, and unpaid amount. Every element has at least one piece of evidence behind it.",
        chatMessage: "Four elements, four supporting pieces of evidence. Brooklyn Bites' own timecards plus your paystubs handle elements 1 and 3. Your manager's text plus your contemporaneous notes handle the off-the-clock hours." },
      { id: 'evidence-list', target: 'evidence-list', screen: 'evidence',
        title: "The burden-shift moment",
        body: "Brooklyn Bites can't produce complete timecards for Sarah's prep work. Under Anderson v. Mt. Clemens Pottery, when an employer can't produce complete records, the worker's reasonable estimate of unpaid hours stands.",
        chatMessage: "Watch what just happened. Brooklyn Bites can't produce complete timecards for your prep work. Under Anderson v. Mt. Clemens Pottery, when an employer can't produce complete records, YOUR reasonable estimate stands. Your notes ARE the record now. This is the move that makes wage cases winnable." },
      { id: 'sidebar-damages', target: 'sidebar-damages', screen: 'evidence',
        title: "What she's owed",
        body: "NY wage damages stack in a specific order. Click Damages.",
        chatMessage: "Now click Damages. The numbers stack in a specific order. Let me walk you through it." },
      { id: 'damages-math', target: 'damages-math', screen: 'damages',
        title: "Total recovery sought: $2,100",
        body: "$240 off-the-clock + $800 overtime premium + $1,040 NY 100% liquidated + $20 filing fee. Three layers, one evidentiary foundation.",
        chatMessage: "$240 in off-the-clock wages, 12 hours times your $20 rate. $800 in overtime premium, 80 hours times the $10 half-time bump. $1,040 in NY 100% liquidated, awarded by default unless Brooklyn Bites can prove good faith. Plus $20 in filing fees. Three layers, same evidentiary foundation." },
      { id: 'sidebar-readiness', target: 'sidebar-readiness', screen: 'damages',
        title: "File-ready check",
        body: "Before generating the packet, Hello Court runs a final readiness pass.",
        chatMessage: "Click Case readiness. Before we generate your packet, let's do one final pass." },
      { id: 'sidebar-documents', target: 'sidebar-documents', screen: 'readiness',
        title: "The filing packet",
        body: "Ten sections drafted from Sarah's affirmed facts.",
        chatMessage: "Click Documents. This is where everything you've affirmed gets turned into court paperwork. Statement of Claim, exhibit index, damages worksheet." },
      { id: 'documents-list', target: 'documents-list', screen: 'documents',
        title: "Documents drafted from her facts",
        body: "Every line in these documents traces back to something Sarah already affirmed.",
        chatMessage: "Ten sections, every line drawn from a fact you already affirmed. Section V leans on the Anderson burden-shift, the Supreme Court rule from Anderson v. Mt. Clemens Pottery: when your employer's time records are missing or wrong, the court accepts your good-faith estimate of the hours. Brooklyn Bites can't argue around their own missing records." },
      { id: 'sidebar-filing', target: 'sidebar-filing', screen: 'documents',
        title: "How she files it",
        body: "Court, fee, hearing timeline, service options. Click Filing & service.",
        chatMessage: "Click Filing & service. NYC Civil Court. Let's pick the venue and the service method." },
      { id: 'filing-summary', target: 'filing-summary', screen: 'filing',
        title: "NYC Civil Court · Kings County",
        body: "Small-claims part, $20 filing fee, court-mailed service is the cheap default for sub-$5K claims.",
        chatMessage: "NYC Civil Court, Kings County, small-claims part. $20 filing fee. Hearings typically 4 to 8 weeks out. Court-mailed service is $10, the cheap default for sub-$5K claims. Pick it and I'll handle the rest." },
      { id: 'sidebar-courtroom', target: 'sidebar-courtroom', screen: 'filing',
        title: "Walking into court ready",
        body: "Last step: courtroom prep.",
        chatMessage: "Click Courtroom prep. NY isn't a sandbox jurisdiction. I can't give you legal advice, but I can show you what a clean opening statement looks like." },
      { id: 'courtroom-opening', target: 'courtroom-opening', screen: 'courtroom',
        title: "Her opening statement, drafted",
        body: "Two to three minutes. The specific dates, the $20/hr rate, the manager's text, and the Anderson burden-shift do the work; outrage doesn't.",
        chatMessage: "Read this aloud once before you go to court. Two to three minutes. Pause where it tells you to pause. The dates, the $20 rate, the manager's text, and the Anderson burden-shift do all the work. Outrage doesn't." },
      { id: 'guide-handoff', target: 'm-guide-rail', screen: 'home',
        title: "Meet your guide",
        body: "The guide on the right walks you through your real case in plain words. It reads documents you upload and finds ones you already have.",
        chatMessage: "That's me. Your guide. I'm here for every step of your case. Upload documents, ask questions, draft what you need. We just walked through your prepared case. Now I'm here to help you actually file it." },
      { id: 'finish', screen: 'courtroom',
        title: "That's the path",
        body: "From a stolen 12 hours and unpaid overtime to a courtroom-ready case, no lawyer required. NYC alone sees 200,000+ wage-theft incidents every year, mostly unfiled because workers can't afford counsel.",
        chatMessage: "That's the path. From a stolen 12 hours and unpaid overtime to a filing-ready case, no lawyer required. NYC alone has 200,000+ wage-theft incidents a year, mostly unfiled because workers can't afford counsel. You can." },
    ],
  },
  /* Story 0.17 — the blank-slate guided tour. Launches on the `newcase`
     matter and walks a brand-new user through how a case comes together:
     theory → evidence → an honest pressure-test → damages → filing → done.
     Interactive (click-to-advance + chat-guide dialogue). Narration is framed
     for a FRESH case (low readiness, "here's how each part works") since the
     newcase home is a just-started shell while the deeper screens show a
     worked example. Reuses the same data-coach targets as Maria/Sarah. */
  newcase: {
    id: 'newcase',
    title: "Starting a case from scratch",
    matter: 'newcase',
    mode: 'interactive',
    steps: [
      { id: 'intro', screen: 'home', matter: 'newcase',
        title: "Your case, from nothing",
        body: "You just started this case. Here's how it comes together.",
        chatMessage: "This is your case. You started it with nothing but what happened to you, and that's exactly enough to begin. My job is to help you turn it into something a court will take seriously, one step at a time. You make every call; I do the heavy lifting in between.\n\nOne honest note: I can't give legal advice or stand in for a lawyer in court. What I can give you is structure, plain language, and a path forward. Click anywhere when you're ready." },
      { id: 'home-hero', target: 'home-hero', screen: 'home',
        title: "Your case at a glance",
        body: "The case type and the anchoring law, sharpening as you decide.",
        chatMessage: "Right at the top: the kind of case this is and the law that anchors it. Yours is still filling in. As you make decisions, this header sharpens to show exactly where your case stands." },
      { id: 'home-stage', target: 'home-stage', screen: 'home',
        title: "Where you are",
        body: "Five stages. You're at the start.",
        chatMessage: "Five stages: Intake, Preparation, Filing, Hearing, Resolution. You're right at the beginning, which is exactly where you should be. I'll keep you pointed at the next thing the whole way." },
      { id: 'home-readiness', target: 'home-readiness', screen: 'home',
        title: "Case readiness",
        body: "How close you are to file-ready. It climbs as you go.",
        chatMessage: "This is how close you are to file-ready. It starts low, that's normal, you just began. It climbs as you pick your legal theory, connect your evidence, and confirm the basics. Watch it move as we go." },
      { id: 'theory-causes', target: 'theory-causes', screen: 'theory',
        title: "Pick the law that fits",
        body: "The laws that fit your facts, and what each makes you prove. You pick.",
        chatMessage: "First real decision: the legal theory. I lay out the laws that fit what you described and show what each one would make you prove. You pick, I don't. The example here is a wage case with a few theories you can stack together." },
      { id: 'evidence-elements', target: 'evidence-elements', screen: 'evidence',
        title: "Connect your evidence",
        body: "Each piece of evidence backs a specific element. You confirm the matches.",
        chatMessage: "Every piece of evidence has to back a specific element of your claim. I suggest which of your records supports which element; you confirm or reject each match. This is how a pile of documents becomes actual proof." },
      { id: 'evidence-list', target: 'evidence-list', screen: 'evidence',
        title: "Your records",
        body: "Your records, with original dates preserved.",
        chatMessage: "Everything you upload lands here, with the original capture dates kept intact so the court can trust when each piece came from. You can review any item before it goes into your filing." },
      { id: 'atticus-check', screen: 'evidence',
        title: "The honest check",
        body: "An honest pressure-test: where you're strong, where you're thin.",
        chatMessage: "Here's where I pressure-test your case the way the other side will. Where are you strong? Where are you thin? If something's weak, I say so plainly and show you how to shore it up. And if it's the kind of case that really needs a lawyer, I'll tell you that too. No false confidence." },
      { id: 'damages-math', target: 'damages-math', screen: 'damages',
        title: "What you could recover",
        body: "What you lost, plus what the statute adds. Line by line.",
        chatMessage: "Once the elements hold up, the money is mostly math. I show it line by line: what you lost, what the statute adds on top, and the filing costs, so a judge can check every number. Nothing inflated." },
      { id: 'filing-summary', target: 'filing-summary', screen: 'filing',
        title: "Where and how to file",
        body: "Court, fee, timeline, and service, laid out plainly.",
        chatMessage: "Last piece: the right court, the fee, the timeline, and how to serve the other side, all laid out plainly. I walk you through the actual filing so you're never guessing." },
      { id: 'finish', screen: 'home',
        title: "That's the path",
        body: "From \"this happened to me\" to a filing you can do yourself.",
        chatMessage: "That's the whole path, from \"this happened to me\" to a case you can file yourself. You stayed in control of every decision; I handled the legal machinery in between. When you're ready, start your real case and I'll be right here for every step." },
    ],
  },
};

const V3CoachmarkOverlay = ({ script, stepIdx, onPrev, onNext, onSkip, onFinish, onScreenChange, onMatterChange }) => {
  const step = script.steps[stepIdx];
  const [rect, setRect] = React.useState(null);
  const [visible, setVisible] = React.useState(false);
  // Interactive mode: scrim clicks outside the target are ignored. Only
  // clicks INSIDE the spotlit rect (or on Next/Done) advance the step.
  // The animated mouse cursor + chat-guide dialogue coach the user.
  const interactive = script.mode === 'interactive';

  // 1. Tell App to switch screens / matters when a step demands it.
  //    Runs first so the DOM is up-to-date before we measure.
  React.useEffect(() => {
    if (step.matter && typeof onMatterChange === 'function') onMatterChange(step.matter);
    if (step.screen && typeof onScreenChange === 'function') onScreenChange(step.screen);
  }, [stepIdx]);

  // 2. Measure the target rect after two animation frames so the screen-
  //    switch from step (1) has time to commit.
  React.useEffect(() => {
    setVisible(false);
    if (!step.target) {
      setRect(null);
      const t = setTimeout(() => setVisible(true), 60);
      return () => clearTimeout(t);
    }
    let cancelled = false;
    let raf1, raf2, retryT;
    const measure = () => {
      if (cancelled) return;
      const el = document.querySelector(`[data-coach="${step.target}"]`);
      if (!el) {
        // Target not yet in DOM (slow render). Retry a couple of times.
        retryT = setTimeout(measure, 120);
        return;
      }
      // Scroll the target into view inside whichever overflow ancestor
      // owns it (V3 screens each have their own scroll panel) before
      // measuring. Without this, targets that sit below the visible
      // fold of a screen panel render with the spotlight off-screen
      // and the user can't scroll to find it because the scrim
      // intercepts pointer events. Instant scroll (not smooth) so
      // the rect we read on the next frame is the post-scroll rect.
      try {
        el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' });
      } catch (e) { /* older browsers — fall through to direct measure */ }
      // One more frame so the scroll commits before we measure.
      requestAnimationFrame(() => {
        if (cancelled) return;
        setRect(el.getBoundingClientRect());
        setVisible(true);
      });
    };
    raf1 = requestAnimationFrame(() => {
      raf2 = requestAnimationFrame(() => measure());
    });
    const onResize = () => {
      if (!step.target) return;
      const el = document.querySelector(`[data-coach="${step.target}"]`);
      if (el) setRect(el.getBoundingClientRect());
    };
    window.addEventListener('resize', onResize);
    return () => {
      cancelled = true;
      cancelAnimationFrame(raf1); cancelAnimationFrame(raf2);
      clearTimeout(retryT);
      window.removeEventListener('resize', onResize);
    };
  }, [stepIdx, step.target, step.screen, step.matter]);

  // 2b. Interactive mode: listen for clicks inside the spotlit rect. When
  //     the user clicks the spotlit element (which fires its own native
  //     onClick — navigating, selecting, etc.), advance the walkthrough.
  //     Document-level listener uses event bubbling so it fires AFTER the
  //     target's own handler. 60 ms deferred dispatch lets any React state
  //     change commit before we move to the next step.
  React.useEffect(() => {
    if (!interactive || !rect) return;
    // Guard against rapid double-clicks. Once a click queues an advance,
    // ignore any further clicks within the same step until the listener
    // is re-bound on the new step (rebind happens via the dep array).
    let advanceLocked = false;
    const onDocClick = (e) => {
      if (advanceLocked) return;
      const x = e.clientX, y = e.clientY;
      const inside = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
      if (!inside) return;
      // Coachmark callout buttons (Skip/Back/Next/Done) live OUTSIDE the
      // rect, so they never match. But guard anyway against the rare case
      // where the callout overlaps the rect.
      if (e.target && e.target.closest && e.target.closest('[data-coachmark-callout]')) return;

      // Choice detection: if the click landed on (or inside) an element
      // tagged with data-cause-id / data-evidence-id / data-service-id /
      // etc., dispatch a window event with the choice key so the chat-
      // guide can surface a reaction message. Step's chatReactions map
      // looks up the right copy.
      let isChoiceClick = false;
      try {
        const choiceEl = e.target && e.target.closest && e.target.closest(
          '[data-cause-id], [data-evidence-id], [data-service-id], [data-walkthrough-choice]'
        );
        if (choiceEl) {
          const key = choiceEl.getAttribute('data-cause-id')
                   || choiceEl.getAttribute('data-evidence-id')
                   || choiceEl.getAttribute('data-service-id')
                   || choiceEl.getAttribute('data-walkthrough-choice');
          if (key) {
            window.dispatchEvent(new CustomEvent('hc-walkthrough-choice', {
              detail: { stepId: step.id, choiceKey: key },
            }));
            isChoiceClick = true;
          }
        }
      } catch (_) {}

      // Choice clicks (theory pick, evidence pick, service pick, etc.) need
      // a longer pause before advancing so the user can READ the reaction
      // message the chat-guide just appended. Regular spotlight-target clicks
      // are routing actions, so they advance fast. The "blinking" James saw
      // at step 5 was the next-step chatMessage racing the reaction at 60ms.
      const advanceDelay = isChoiceClick ? 2800 : 60;
      // Lock NOW so any subsequent click during the delay is ignored. The
      // double-click jump bug at step N happened because two clicks both
      // queued onNext setTimeouts before the first one fired, taking us
      // from N to N+2 in a single tick.
      advanceLocked = true;
      setTimeout(() => { isLast ? onFinish() : onNext(); }, advanceDelay);
    };
    document.addEventListener('click', onDocClick);
    return () => document.removeEventListener('click', onDocClick);
  }, [interactive, rect, isLast, onNext, onFinish, stepIdx, step.id]);

  // 3. Keyboard shortcuts: Esc skips, Arrows step.
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') { e.preventDefault(); onSkip(); }
      else if (e.key === 'ArrowRight') { e.preventDefault(); stepIdx === script.steps.length - 1 ? onFinish() : onNext(); }
      else if (e.key === 'ArrowLeft' && stepIdx > 0) { e.preventDefault(); onPrev(); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [stepIdx, onPrev, onNext, onSkip, onFinish, script.steps.length]);

  const isLast = stepIdx === script.steps.length - 1;
  // calloutH is a CONSERVATIVE max-height estimate used by the fit-check.
  // Interactive mode: callout strips body copy (chat-guide carries the
  // dialogue), so a tighter 280x140 fits everywhere. Autoplay mode keeps
  // the larger 360x320 because its callout still renders step.body.
  const calloutW = interactive ? 320 : 360;
  const calloutH = interactive ? 140 : 320;
  const gap = 18, edge = 20;

  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }

  // Compute callout position: prefer below the spotlight, then above, right, left.
  // No spotlight → center modal.
  let calloutPos = { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
  if (rect) {
    const W = window.innerWidth, H = window.innerHeight;
    const cx = rect.left + rect.width / 2;
    const fits = {
      bottom: rect.bottom + gap + calloutH + edge < H,
      top: rect.top - gap - calloutH - edge > 0,
      right: rect.right + gap + calloutW + edge < W,
      left: rect.left - gap - calloutW - edge > 0,
    };
    const pref = step.position && step.position !== 'auto' ? step.position : null;
    const order = pref ? [pref, 'bottom', 'top', 'right', 'left'] : ['bottom', 'top', 'right', 'left'];
    const choice = order.find((o) => fits[o]) || 'bottom';
    if (choice === 'bottom') {
      calloutPos = { top: rect.bottom + gap, left: clamp(cx - calloutW / 2, edge, W - calloutW - edge) };
    } else if (choice === 'top') {
      calloutPos = { top: rect.top - gap - calloutH, left: clamp(cx - calloutW / 2, edge, W - calloutW - edge) };
    } else if (choice === 'right') {
      const cy = rect.top + rect.height / 2;
      calloutPos = { top: clamp(cy - calloutH / 2, edge, H - calloutH - edge), left: rect.right + gap };
    } else {
      const cy = rect.top + rect.height / 2;
      calloutPos = { top: clamp(cy - calloutH / 2, edge, H - calloutH - edge), left: rect.left - gap - calloutW };
    }
    // Final viewport clamp — guarantees the card never extends past the
    // viewport edge no matter how tall it actually renders. The card's
    // own max-height + overflow-y handle the case where content is too
    // tall to fit even after clamping.
    if (typeof calloutPos.top === 'number') {
      calloutPos.top = clamp(calloutPos.top, edge, H - calloutH - edge);
    }
    if (typeof calloutPos.left === 'number') {
      calloutPos.left = clamp(calloutPos.left, edge, W - calloutW - edge);
    }
  }

  const overlay = (
    <div style={{
      position: 'fixed', inset: 0, zIndex: 9000,
      pointerEvents: 'none',
      opacity: visible ? 1 : 0,
      transition: 'opacity 220ms ease',
      fontFamily: "'Inter', system-ui, sans-serif",
    }}>
      {/* Dimmed scrim.
          Non-interactive mode: SVG mask, clicks anywhere advance (legacy).
          Interactive mode: four real DOM divs around the spotlight, leaving
          the spotlit rect as an actual click-through hole. The user can
          actually click the spotlit element and its native handlers fire.
          The walkthrough engine listens for that click via a delegated
          document-level handler and advances when the target was clicked. */}
      {interactive && rect ? (
        /* 4-div pass-through layout. Hole = literal rect of empty space.
           Each surrounding div has pointer-events:auto so it absorbs clicks
           on the dim area (preventing accidental clicks on non-spotlit UI),
           but offers no advance behavior of its own. Use SCRIM_PAD to match
           the spotlight ring's 8px outer offset. */
        (() => {
          const SCRIM_PAD = 8;
          const hx = rect.left - SCRIM_PAD, hy = rect.top - SCRIM_PAD;
          const hw = rect.width + SCRIM_PAD * 2, hh = rect.height + SCRIM_PAD * 2;
          const dim = 'rgba(10,16,26,0.62)';
          const cssTransition = 'top 260ms cubic-bezier(0.4,0,0.2,1), left 260ms cubic-bezier(0.4,0,0.2,1), width 260ms cubic-bezier(0.4,0,0.2,1), height 260ms cubic-bezier(0.4,0,0.2,1)';
          return (
            <>
              {/* Top band */}
              <div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: hy, background: dim, pointerEvents: 'auto', transition: cssTransition }}/>
              {/* Bottom band */}
              <div style={{ position: 'absolute', top: hy + hh, left: 0, right: 0, bottom: 0, background: dim, pointerEvents: 'auto', transition: cssTransition }}/>
              {/* Left band */}
              <div style={{ position: 'absolute', top: hy, left: 0, width: hx, height: hh, background: dim, pointerEvents: 'auto', transition: cssTransition }}/>
              {/* Right band */}
              <div style={{ position: 'absolute', top: hy, left: hx + hw, right: 0, height: hh, background: dim, pointerEvents: 'auto', transition: cssTransition }}/>
            </>
          );
        })()
      ) : interactive && !rect ? (
        /* Interactive but no target (intro / finish). Full dim. The callout
           is gone — chat-guide carries the voice — so click anywhere on
           the scrim to advance. Esc still skips. */
        <div
          style={{ position: 'absolute', inset: 0, background: 'rgba(10,16,26,0.62)', pointerEvents: 'auto', cursor: 'pointer' }}
          onClick={() => { isLast ? onFinish() : onNext(); }}
        />
      ) : (
        /* Non-interactive (autoplay). Legacy SVG-mask scrim, clicks advance. */
        <svg
          style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'auto', cursor: rect ? 'pointer' : 'default' }}
          onClick={() => { if (!rect) return; isLast ? onFinish() : onNext(); }}>
          <defs>
            <mask id={`v3-coachmark-mask-${stepIdx}`}>
              <rect width="100%" height="100%" fill="#D7DEE2"/>
              {rect && (
                <rect
                  x={rect.left - 8} y={rect.top - 8}
                  width={rect.width + 16} height={rect.height + 16}
                  rx="14" ry="14" fill="black"/>
              )}
            </mask>
          </defs>
          <rect width="100%" height="100%" fill="rgba(10,16,26,0.62)" mask={`url(#v3-coachmark-mask-${stepIdx})`}/>
        </svg>
      )}

      {/* Spotlight ring around the cutout. */}
      {rect && (
        <div style={{
          position: 'absolute',
          top: rect.top - 8, left: rect.left - 8,
          width: rect.width + 16, height: rect.height + 16,
          borderRadius: 14,
          border: '2px solid rgba(159,179,255,0.85)',
          boxShadow: '0 0 0 4px rgba(159,179,255,0.18), 0 18px 50px rgba(0,0,0,0.55)',
          pointerEvents: 'none',
          transition: 'all 260ms cubic-bezier(0.4, 0, 0.2, 1)',
        }}/>
      )}

      {/* Animated mouse cursor for interactive mode. Slides smoothly from
          previous step's target to current step's target so the user can see
          where they're being guided. The cursor pulses on arrival (keyed by
          stepIdx) so the user notices it parked over the click target. */}
      {interactive && rect && (
        <MouseCursor rect={rect} stepIdx={stepIdx}/>
      )}

      {/* Callout card. Suppressed entirely in interactive mode — the chat-
          guide on the right is the only dialogue surface, and the spotlight
          handles spatial pointing. Esc skips, arrow keys step, clicks on
          the spotlit target (or the scrim, for no-target steps) advance. */}
      {!interactive && (
      <div data-coachmark-callout="true" style={{
        position: 'absolute',
        top: calloutPos.top, left: calloutPos.left, transform: calloutPos.transform || 'none',
        width: calloutW,
        maxHeight: `calc(100vh - ${edge * 2}px)`,
        overflowY: 'auto',
        background: 'linear-gradient(180deg, rgba(48,64,84,0.75), rgba(38,52,68,0.75))',
        border: '1px solid rgba(159,179,255,0.38)',
        borderRadius: 18,
        boxShadow: '0 28px 70px rgba(0,0,0,0.60), inset 0 1px 0 rgba(255,255,255,0.05)',
        padding: '22px 24px 18px',
        color: '#F2F4F8',
        pointerEvents: 'auto',
        backdropFilter: 'blur(20px) saturate(1.4)',
        WebkitBackdropFilter: 'blur(20px) saturate(1.4)',
        transition: 'top 260ms cubic-bezier(0.4, 0, 0.2, 1), left 260ms cubic-bezier(0.4, 0, 0.2, 1)',
      }}>
        <div style={{
          display: 'flex', justifyContent: 'space-between', alignItems: 'center',
          fontSize: 10.5, letterSpacing: 1.4, textTransform: 'uppercase',
          color: 'rgba(159,179,255,0.85)', marginBottom: 12,
        }}>
          <span>Step {stepIdx + 1} of {script.steps.length}</span>
          <span style={{ display: 'flex', gap: 4 }}>
            {script.steps.map((_, i) => (
              <span key={i} style={{
                width: i === stepIdx ? 14 : 6, height: 6, borderRadius: 3,
                background: i === stepIdx ? '#9FB3FF' : (i < stepIdx ? 'rgba(159,179,255,0.55)' : 'rgba(159,179,255,0.20)'),
                transition: 'all 220ms ease',
              }}/>
            ))}
          </span>
        </div>
        <div style={{
          fontFamily: "'Fraunces', Georgia, serif", fontSize: 20, fontWeight: 500,
          letterSpacing: -0.3, lineHeight: 1.22,
          marginBottom: interactive ? 16 : 10, color: '#F8F4E9',
        }}>
          {step.title}
        </div>
        {/* Body copy only renders in autoplay mode. Interactive mode hands
            dialogue off to the chat-guide panel; the callout becomes lean
            chrome (step indicator + title + buttons). */}
        {!interactive && (
          <div style={{
            fontSize: 14, lineHeight: 1.55,
            color: 'rgba(242,244,248,0.78)', marginBottom: 18,
          }}>
            {step.body}
          </div>
        )}
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
          {/* Interactive mode shrinks the Skip label to "Skip" — Esc also
              skips, and the tight 280-wide callout can't fit the full
              "Skip walkthrough" + Back + Next row otherwise. */}
          <Btn kind="ghost" onClick={onSkip}>{interactive ? 'Skip' : 'Skip walkthrough'}</Btn>
          <div style={{ display: 'flex', gap: 8 }}>
            {stepIdx > 0 && <Btn kind="secondary" onClick={onPrev}>Back</Btn>}
            <Btn kind="primary" onClick={isLast ? onFinish : onNext}>
              {isLast ? 'Done' : 'Next'}
            </Btn>
          </div>
        </div>
      </div>
      )}
    </div>
  );

  // Portal to .scene so the scrim and the chat-guide panel live in the SAME
  // stacking context. demo.html's .scene wrapper is `position: fixed`, which
  // creates its own stacking context; portaling to body instead leaves the
  // chat-guide trapped inside .scene's context where its zIndex can't reach
  // above the body-portaled scrim. Fall back to body if .scene isn't present
  // yet (early render) so legacy autoplay walkthroughs still work.
  const portalRoot = document.querySelector('.scene') || document.body;
  return ReactDOM.createPortal(overlay, portalRoot);
};

/* MouseCursor. Animated virtual cursor used during interactive walkthroughs.
   Slides between target rects via CSS transition on top/left. Renders the
   classic OS pointer SVG plus a click-pulse ring keyed off stepIdx so the
   user sees a "tap" beat on each new step.

   Sits above the SVG scrim (z-index implicit via render order inside the
   overlay div). Pointer-events:none so it never blocks the user's real
   clicks. */
const MouseCursor = ({ rect, stepIdx }) => {
  // Park the cursor a few pixels inside the top-left of the target so it
  // visibly sits ON the spotlit element, not floating over its center
  // (which would obscure short labels like "Evidence" or "Damages").
  const cx = rect.left + Math.min(rect.width * 0.32, 36);
  const cy = rect.top  + Math.min(rect.height * 0.52, 26);

  React.useEffect(() => {
    // CSS keyframes for the click pulse, injected once.
    if (document.getElementById('v3-coachmark-cursor-css')) return;
    const css = `
      @keyframes v3CursorPulse {
        0%   { transform: scale(0.5); opacity: 0.55; }
        80%  { transform: scale(1.8); opacity: 0; }
        100% { transform: scale(1.8); opacity: 0; }
      }
    `;
    const el = document.createElement('style');
    el.id = 'v3-coachmark-cursor-css';
    el.textContent = css;
    document.head.appendChild(el);
  }, []);

  return (
    <div style={{
      position: 'absolute',
      top: cy, left: cx,
      pointerEvents: 'none',
      transition: 'top 540ms cubic-bezier(0.32, 0.72, 0.18, 1), left 540ms cubic-bezier(0.32, 0.72, 0.18, 1)',
      width: 0, height: 0,
    }}>
      {/* Pulse ring keyed off stepIdx so it re-fires on every step change. */}
      <div key={stepIdx} style={{
        position: 'absolute',
        top: -10, left: -10,
        width: 20, height: 20,
        borderRadius: '50%',
        background: 'rgba(159,179,255,0.55)',
        animation: 'v3CursorPulse 760ms ease-out 540ms forwards',
        transformOrigin: 'center',
      }}/>
      {/* Pointer SVG. Sized so the tip lands at (0,0) of this container. */}
      <svg width="22" height="28" viewBox="0 0 22 28" fill="none" style={{
        position: 'absolute', top: 0, left: 0,
        filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.55))',
      }}>
        <path
          d="M2 2 L2 22 L8 17 L11 24 L14 23 L11 16 L18 16 Z"
          fill="#F8F4E9"
          stroke="#1A232C"
          strokeWidth="1.5"
          strokeLinejoin="round"/>
      </svg>
    </div>
  );
};

window.V3_WALKTHROUGHS = V3_WALKTHROUGHS;
window.V3CoachmarkOverlay = V3CoachmarkOverlay;
window.MouseCursor = MouseCursor;
