// results-page.jsx — dedicated results page shown after the hero "Start" button.
// Composes: brief analyzing animation → full inspection report with all sections.

// ─────────────────────────────────────────────────────────────────────────────
// UPLOADED PHOTO — shows the real user photo with optional scanning overlay
const UploadedPhoto = ({ file, src: srcProp, scanning = false }) => {
  // Renders either an in-memory File (consumer flow → object URL) or a ready image URL
  // (`src` — e.g. an inspection photo from the API, used on deep-link/refresh once the
  // uploaded File is gone). `src` wins when both are present.
  const [objUrl, setObjUrl] = React.useState(null);
  React.useEffect(() => {
    if (!file) { setObjUrl(null); return; }
    const url = URL.createObjectURL(file);
    setObjUrl(url);
    return () => URL.revokeObjectURL(url);
  }, [file]);

  const src = srcProp || objUrl;
  if (!src) return null;

  const accent = "#10A171";
  return (
    <div style={{ position: "relative", width: "100%", borderRadius: 12, overflow: "hidden", lineHeight: 0 }}>
      <img src={src} alt={t("UploadedPhoto.altDamagePhoto")} style={{ width: "100%", display: "block", borderRadius: 12, objectFit: "cover", maxHeight: 340 }} />
      {scanning && (
        <>
          <div style={{
            position: "absolute", left: 0, right: 0, top: 0, height: "30%",
            background: `linear-gradient(180deg, ${accent}00 0%, ${accent}22 50%, ${accent}55 95%, ${accent}00 100%)`,
            animation: "scanLine 1.6s ease-in-out infinite alternate",
            mixBlendMode: "screen",
          }} />
          <div style={{
            position: "absolute", left: 12, top: 12,
            color: "#fff", fontSize: 11, fontFamily: "var(--mono)", letterSpacing: ".06em",
            background: "rgba(0,0,0,.55)", padding: "4px 8px", borderRadius: 6,
            display: "flex", alignItems: "center", gap: 6,
          }}>
            <span className="dot" style={{ background: accent, animation: "pulseSoft 1s infinite" }} /> {t("UploadedPhoto.scanning")}
          </div>
        </>
      )}
    </div>
  );
};

// Brief neutral loading state while we probe an inspection's status — avoids
// flashing the analyzing animation for an inspection that's already finished.
const CheckingHero = () => (
  <div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
    <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 16 }}>
      <span style={{ width: 28, height: 28, border: "2px solid var(--rule-2)", borderTopColor: "var(--ink)", borderRadius: 50, animation: "spin .8s linear infinite" }} />
      <div className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>{t("ResultsLoading.loadingText")}</div>
    </div>
  </div>
);

// ─────────────────────────────────────────────────────────────────────────────
// TERMINAL MESSAGE — centered card for the non-report outcomes (no damage / error).
const ResultMessage = ({ tone = "ok", title, message, actionLabel, onAction }) => {
  const isError = tone === "error";
  return (
    <div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", padding: 28 }}>
      <div className="card" style={{
        padding: "44px 36px", maxWidth: 440, width: "100%",
        textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", gap: 18,
        boxShadow: "var(--shadow-md)",
      }}>
        <div style={{
          width: 56, height: 56, borderRadius: 16,
          background: isError ? "#FFF1F2" : "var(--accent-tint)",
          color: isError ? "#BE123C" : "var(--accent-ink)",
          display: "grid", placeItems: "center",
          fontSize: 26, fontWeight: 700, fontFamily: "var(--sans)", lineHeight: 1,
        }}>
          {isError ? "!" : <Ic.check width="28" height="28" />}
        </div>
        <div>
          <div style={{ fontSize: 21, fontWeight: 700, letterSpacing: "-0.02em", marginBottom: 8 }}>{title}</div>
          <p style={{ margin: 0, fontSize: 14.5, color: "var(--ink-2)", lineHeight: 1.6, maxWidth: "34ch" }}>{message}</p>
        </div>
        {actionLabel && (
          <button className={isError ? "btn btn-primary" : "btn btn-accent"} onClick={onAction} style={{ marginTop: 4, justifyContent: "center" }}>
            {actionLabel} <Ic.arrow width="14" height="14" />
          </button>
        )}
      </div>
    </div>
  );
};

// The terminal-status vocabulary of the inspection pipeline, shared by every consumer on this
// page (live stream rows, inspection polls, the initial probe): returns the lowercased status
// when it is terminal — which doubles as the `finish(kind)` handoff value — and null otherwise.
const terminalKind = (status) => {
  const st = String(status || "").toLowerCase();
  return st === "completed" || st === "validationfailed" || st === "failed" ? st : null;
};

// Map a fetched inspection to a terminal outcome from its own status: a failed image
// validation → validation page; a failed run → error page; a completed run with zero
// damages → no-damage page; a completed run with damages → full report. Anything still
// non-terminal (Pending/Uploaded/InProgress) or unknown resolves to the error page — never a
// fabricated report for an unfinished inspection. `InspectionStatus` carries `ValidationFailed`
// directly, so no separate process-status lookup is needed here.
const resolveInspectionOutcome = (ins) => {
  const st = String((ins && ins.status) || "").toLowerCase();
  if (st === "validationfailed") return "validationfailed";
  if (st === "failed") return "error";
  if (st === "completed") {
    const count = ins && typeof ins.damageCount === "number"
      ? ins.damageCount
      : (ins && Array.isArray(ins.damages) ? ins.damages.length : null);
    return count === 0 ? "nodamage" : "report";
  }
  // The backend guarantees a terminal status, so a non-terminal one here means "not ready" —
  // surface the error page rather than an empty report.
  return "error";
};

const ResultsPage = ({ severity, onHome, inspectionId, uploadedPhoto }) => {
  // For a real inspection, probe its authoritative status before rendering anything.
  // An already-finished inspection (e.g. a deep-linked /inspection/<id>) resolves to
  // its terminal outcome directly — the live progress stream tops out at the last
  // persisted % and never replays a terminal event, so animating it would hang at ~95%.
  const [phase, setPhase] = React.useState("checking"); // checking | analyzing | resolving | ready
  const [outcome, setOutcome] = React.useState(null); // report | nodamage | validationfailed | error | notfound
  const [inspection, setInspection] = React.useState(null); // full report payload for a real inspection
  const finalStatusRef = React.useRef(null);

  // A real inspection id is required — the no-inspection demo path has been retired.
  // Reaching here without one (e.g. a stale deep link) redirects home.
  React.useEffect(() => { if (!inspectionId) onHome(); }, [inspectionId]);

  // Reset when switching between inspections.
  React.useEffect(() => {
    setPhase("checking");
    setOutcome(null);
    setInspection(null);
    finalStatusRef.current = null;
  }, [inspectionId]);

  // Real inspection, initial probe → a terminal status (Completed/Failed/ValidationFailed)
  // resolves its outcome directly from the inspection; anything still in flight
  // (Pending/Uploaded/InProgress, or any unknown value) watches live processing.
  React.useEffect(() => {
    if (phase !== "checking" || !inspectionId) return;
    let cancelled = false;
    OK_API.getInspection(inspectionId)
      .then((ins) => {
        if (cancelled) return;
        setInspection(ins);
        if (terminalKind(ins && ins.status)) {
          setOutcome(resolveInspectionOutcome(ins));
          setPhase("ready");
        } else {
          setPhase("analyzing");
        }
      })
      .catch((e) => {
        if (cancelled) return;
        // A 404 (no such inspection) or 400 (malformed id) means an invalid link → show the
        // not-found page. `/inspection/*` is always rewritten to the SPA, so the static host
        // never 404s these — the SPA has to surface it. Transient failures (5xx / network)
        // keep the live-progress fallback, whose SSE+poll channel is more resilient than this
        // one-shot probe.
        if (e && (e.status === 404 || e.status === 400)) { setOutcome("notfound"); setPhase("ready"); }
        else setPhase("analyzing");
      });
    return () => { cancelled = true; };
  }, [phase, inspectionId]);

  // Live analyzing just finished → fetch the final inspection to read its damage count
  // (the progress stream carries status but not damages), then resolve the outcome.
  React.useEffect(() => {
    if (phase !== "resolving" || !inspectionId) return;
    let cancelled = false;
    OK_API.getInspection(inspectionId)
      .then((ins) => { if (!cancelled) { setInspection(ins); setOutcome(resolveInspectionOutcome(ins)); setPhase("ready"); } })
      .catch(() => { if (!cancelled) { setOutcome(finalStatusRef.current === "failed" ? "error" : "report"); setPhase("ready"); } });
    return () => { cancelled = true; };
  }, [phase, inspectionId]);

  // Analyzing handoff: failures resolve directly (no report to fetch). A completed run
  // usually arrives WITH the inspection that triggered the finish (the analyzing poll just
  // fetched it) — resolve from that copy instead of re-fetching the full report. Only an
  // SSE-triggered completion (status row, no inspection payload) routes through `resolving`.
  const handleAnalyzingDone = (status, ins) => {
    if (status === "failed") { setOutcome("error"); setPhase("ready"); return; }
    if (status === "validationfailed") { setOutcome("validationfailed"); setPhase("ready"); return; }
    if (ins) { setInspection(ins); setOutcome(resolveInspectionOutcome(ins)); setPhase("ready"); return; }
    finalStatusRef.current = status || null; setPhase("resolving");
  };

  if (!inspectionId) return null; // redirecting home (see effect above)

  return (
    <main style={{ background: "var(--bg)", minHeight: "100vh" }}>
      {phase === "checking" || phase === "resolving"
        ? <CheckingHero />
        : phase === "analyzing"
          ? <LiveAnalyzingHero severity={severity} inspectionId={inspectionId} uploadedPhoto={uploadedPhoto} onDone={handleAnalyzingDone} />
          : outcome === "nodamage"
            ? <ResultMessage tone="ok" title={t("ResultMessage.nodamageTitle")} message={t("ResultMessage.nodamageBody")} actionLabel={t("ResultMessage.nodamageAction")} onAction={onHome} />
            : outcome === "validationfailed"
              ? <ResultMessage tone="error" title={t("ResultMessage.validationTitle")} message={t("ResultMessage.validationBody")} actionLabel={t("ResultMessage.validationAction")} onAction={onHome} />
              : outcome === "notfound"
                ? <ResultMessage tone="error" title={t("ResultMessage.notfoundTitle")} message={t("ResultMessage.notfoundBody")} actionLabel={t("ResultMessage.notfoundAction")} onAction={onHome} />
                : outcome === "error"
                  ? <ResultMessage tone="error" title={t("ResultMessage.errorTitle")} message={t("ResultMessage.errorBody")} actionLabel={t("ResultMessage.errorAction")} onAction={onHome} />
                  : <InspectionReport severity={severity} onHome={onHome} uploadedPhoto={uploadedPhoto} inspection={inspection} inspectionId={inspectionId} />}
    </main>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// LIVE ANALYZING — real processing progress via SSE, with status polling fallback.

// Poll cadence: the dev API rate-limits ~100 requests/min per IP (429s are swallowed by the
// poll catches), so the poll must stay a low-frequency safety net — SSE is the live channel.
const ANALYZING_POLL_MS = 30000;
// Last-resort cap: the backend's stuck-run watchdog guarantees a terminal status within
// ~20 minutes — this is that plus a margin, so it only fires if the channel silently fails.
const ANALYZING_HARDSTOP_MS = 21 * 60 * 1000;

const LiveAnalyzingHero = ({ severity, inspectionId, uploadedPhoto, onDone }) => {
  const [updates, setUpdates] = React.useState([]); // {message, progressPercentage, status}
  // Rendered percentage is the max ever seen, not the latest received: SSE pushes and the
  // 30s poll interleave, so a stale lower row can arrive after a newer one — the bar must
  // never move backwards. maxPctRef is the synchronous authority (handleStatus compares
  // against it before React re-renders); pct mirrors it for rendering.
  const [pct, setPct] = React.useState(0);
  const maxPctRef = React.useRef(0);
  const [failed, setFailed] = React.useState(false);
  const doneRef = React.useRef(false);

  React.useEffect(() => {
    let es = null;
    let pollId = null;
    let failTimer = null;
    doneRef.current = false; // fresh inspection → fresh lifecycle (cleanup sets it true)
    maxPctRef.current = 0;
    setPct(0);

    // kind: a terminalKind() value — the outcome to hand off. `ins` is the inspection that
    // triggered the finish (when one did): passing it along lets the outcome page resolve
    // without re-fetching the full report it was just handed.
    const finish = (kind, ins) => {
      if (doneRef.current) return;
      doneRef.current = true;
      if (es) es.close();
      if (pollId) clearInterval(pollId);
      if (kind !== "completed") setFailed(true); // red progress-bar cue
      // Brief beat so the final 100% state renders, then hand off to the outcome
      // page. Failures use the same short delay — the outcome screen carries the
      // message, so there's no transient note to linger on.
      failTimer = setTimeout(() => onDone(kind, ins || null), 600);
    };

    const handleStatus = (s) => {
      if (!s || doneRef.current) return;
      const kind = terminalKind(s.status);
      // The pipeline emits strictly increasing percentages, so a lower non-terminal row is
      // by construction a stale out-of-order duplicate — drop it (terminal rows always pass).
      const p = Number(s.progressPercentage) || 0;
      if (!kind && p < maxPctRef.current) return;
      maxPctRef.current = Math.max(maxPctRef.current, p);
      setPct(maxPctRef.current);
      // The inspection API now returns free-text (incl. `message`) as bilingual
      // { en, de } objects. Collapse to the active language here at the boundary so
      // the list renders a plain string and the dedup compare below works by value.
      const message = L(s.message);
      setUpdates((prev) => {
        const last = prev[prev.length - 1];
        if (last && last.message === message && last.progressPercentage === s.progressPercentage) return prev;
        return [...prev.slice(-7), { ...s, message }];
      });
      if (kind) finish(kind);
    };

    // Poll current status alongside the stream. The SSE stream only pushes on a
    // progress *change* and never replays state to a freshly-connected client, so a
    // client that attaches mid-step (or while processing is briefly idle) would
    // otherwise sit blank. getProcessStatus returns the current row — seed from it
    // immediately, then keep polling as the reliability backbone. handleStatus
    // dedupes, so overlapping SSE + poll updates don't churn the UI.
    //
    // getProcessStatus drives the live messages/percentage, but it is NOT reliable for the
    // *terminal* decision: the AI finalizes an inspection (verdict → Inspection.Status = Completed)
    // without always posting a terminal process-status row, so the stream can top out around 95%
    // ("Generating inspection summary…") indefinitely. getInspection carries the authoritative
    // status, so poll it too and finish off that — otherwise analyzing hangs at ~95% on a run that
    // has actually completed.
    const finishFromInspection = (ins) => {
      if (!ins || doneRef.current) return;
      const kind = terminalKind(ins.status);
      if (kind) finish(kind, ins);
    };
    const poll = () => {
      OK_API.getProcessStatus(inspectionId).then(handleStatus).catch(() => {});
      OK_API.getInspection(inspectionId).then(finishFromInspection).catch(() => {});
    };
    // Seed with the current process-status row only — the `checking` probe fetched the
    // inspection itself moments ago, so the getInspection safety net starts with the interval.
    OK_API.getProcessStatus(inspectionId).then(handleStatus).catch(() => {});
    pollId = setInterval(poll, ANALYZING_POLL_MS);

    es = OK_API.openInspectionEvents(inspectionId, { onStatus: handleStatus }); // reconnects internally

    // Background tabs throttle setInterval (and may suspend it outright on mobile), so the
    // poll can sit idle past completion — reconcile immediately when the tab comes back.
    // (visibilitychange alone covers this: a focus change without a visibility change means
    // the tab was visible all along, so the interval was never throttled.)
    const onVisible = () => {
      if (!document.hidden && !doneRef.current) poll();
    };
    document.addEventListener("visibilitychange", onVisible);
    // On expiry, consult the authoritative inspection status once and finish on the *real*
    // outcome; never fake "completed" (which would render an empty report). Failed or still
    // non-terminal → honest error.
    const hardStop = setTimeout(() => {
      if (doneRef.current) return;
      OK_API.getInspection(inspectionId)
        .then((ins) => finish(terminalKind(ins && ins.status) || "failed", ins))
        .catch(() => finish("failed"));
    }, ANALYZING_HARDSTOP_MS);

    return () => {
      doneRef.current = true;
      if (es) es.close();
      if (pollId) clearInterval(pollId);
      if (failTimer) clearTimeout(failTimer);
      clearTimeout(hardStop);
      document.removeEventListener("visibilitychange", onVisible);
    };
  }, [inspectionId]);

  return (
    <div style={{ minHeight: "100vh", display: "flex", alignItems: "center" }}>
    <div className="wrap analyzing-page-grid" style={{ padding: "80px 28px 120px", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 96, alignItems: "center", width: "100%" }}>
      <div className="card" style={{ padding: 14 }}>
        {uploadedPhoto
          ? <UploadedPhoto file={uploadedPhoto} scanning />
          : <CarPhoto severity={severity} showOverlay={false} scanning />}
      </div>
      <div>
        <h1 className="h2">{t("AnalyzingHero.heading")}</h1>
        <p className="lead" style={{ marginTop: 14, maxWidth: "44ch" }}>
          {t("AnalyzingHero.lead")}
        </p>

        {/* Progress bar */}
        <div style={{ marginTop: 28, maxWidth: 460 }}>
          <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
            <span className="tiny mono">{t("AnalyzingHero.inspectionLabel")} {inspectionId.slice(-8)}</span>
            <span className="tiny mono">{pct}%</span>
          </div>
          <div style={{ height: 6, borderRadius: 4, background: "var(--rule-2)", overflow: "hidden" }}>
            <div style={{
              height: "100%", width: `${Math.max(pct, 4)}%`,
              background: failed ? "#BE123C" : "var(--accent)",
              borderRadius: 4, transition: "width .6s ease",
            }} />
          </div>
        </div>

        <ul style={{ padding: 0, margin: "24px 0 0", listStyle: "none", display: "flex", flexDirection: "column", gap: 4, maxWidth: 460 }}>
          {updates.length === 0 && (
            <li style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 14px", borderRadius: 8, background: "var(--paper)", border: "1px solid var(--rule)" }}>
              <span style={{ width: 12, height: 12, border: "1.5px solid var(--mute-2)", borderTopColor: "var(--ink)", borderRadius: 50, animation: "spin .8s linear infinite", flexShrink: 0 }} />
              <span className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>{t("AnalyzingHero.queuing")}</span>
            </li>
          )}
          {updates.map((u, i) => {
            const active = i === updates.length - 1 && !doneRef.current;
            return (
              <li key={i} style={{
                display: "flex", alignItems: "center", gap: 12,
                padding: "10px 14px", borderRadius: 8,
                background: active ? "var(--paper)" : "transparent",
                border: active ? "1px solid var(--rule)" : "1px solid transparent",
                transition: "all .2s",
              }}>
                <span style={{ width: 16, height: 16, display: "grid", placeItems: "center", flexShrink: 0 }}>
                  {active && String(u.status).toLowerCase() === "inprogress"
                    ? <span style={{ width: 12, height: 12, border: "1.5px solid var(--mute-2)", borderTopColor: "var(--ink)", borderRadius: 50, animation: "spin .8s linear infinite" }} />
                    : <Ic.check width="14" height="14" style={{ color: String(u.status).toLowerCase() === "failed" ? "#BE123C" : "var(--accent)" }} />}
                </span>
                <span className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>
                  {u.message}
                </span>
              </li>
            );
          })}
        </ul>
      </div>
      <style>{`@media (max-width:880px){.analyzing-page-grid{grid-template-columns:1fr !important; gap:48px !important; padding-top:48px !important; padding-bottom:64px !important}}`}</style>
    </div>
    </div>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// SIGN-UP GATE — real consumer email capture layered over the report.
// Two-step: attach email (sends a 6-digit code) → verify code (unlocks the report).

const OK_INPUT_STYLE = {
  fontFamily: "var(--sans)", fontSize: 14,
  color: "var(--ink)", background: "var(--bg)",
  border: "1px solid var(--rule-2)", borderRadius: 8,
  padding: "10px 14px", outline: "none", width: "100%", boxSizing: "border-box",
};

const SignUpOverlay = ({ inspectionId, onUnlocked }) => {
  const [step, setStep] = React.useState("email"); // email | code
  const [email, setEmail] = React.useState("");
  const [code, setCode] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);

  const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
  const codeValid = /^\d{6}$/.test(code);

  const sendCode = async () => {
    if (!emailValid || busy) return;
    setError(null);
    setBusy(true);
    try {
      await OK_API.attachInspectionEmail(inspectionId, email.trim());
      setStep("code");
    } catch (e) {
      setError(e.message || t("SignUpOverlay.sendError"));
    } finally {
      setBusy(false);
    }
  };

  const verify = async () => {
    if (!codeValid || busy) return;
    setError(null);
    setBusy(true);
    try {
      await OK_API.verifyInspectionEmail(inspectionId, code);
      onUnlocked();
    } catch (e) {
      setError(e.status === 400
        ? t("SignUpOverlay.codeInvalid")
        : (e.message || t("SignUpOverlay.verifyError")));
    } finally {
      setBusy(false);
    }
  };

  return (
    <div data-testid="signup-overlay" style={{ position: "absolute", inset: 0, display: "flex", alignItems: "flex-start", justifyContent: "center", paddingTop: 48 }}>
      <div className="card" style={{
        padding: "36px 32px", maxWidth: 420, width: "100%",
        textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", gap: 16,
        boxShadow: "var(--shadow-md)", backdropFilter: "blur(2px)",
      }}>
        <div style={{
          width: 48, height: 48, borderRadius: 14,
          background: "var(--accent-tint)", color: "var(--accent-ink)",
          display: "grid", placeItems: "center",
        }}>
          <Ic.sparkle width="22" height="22" />
        </div>

        {step === "email" ? (
          <>
            <div>
              <div style={{ fontSize: 19, fontWeight: 700, letterSpacing: "-0.02em", marginBottom: 8 }}>
                {t("SignUpOverlay.emailHeading")}
              </div>
              <p style={{ margin: 0, fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.6 }}>
                {t("SignUpOverlay.emailBody")}
              </p>
            </div>
            <form onSubmit={(e) => { e.preventDefault(); sendCode(); }} style={{ display: "flex", flexDirection: "column", gap: 12, width: "100%" }}>
              <input
                type="email"
                data-testid="signup-email"
                placeholder={t("SignUpOverlay.emailPlaceholder")}
                value={email}
                onChange={(e) => { setEmail(e.target.value); setError(null); }}
                disabled={busy}
                style={OK_INPUT_STYLE}
              />
              {error && <div style={{ fontSize: 12.5, color: "#BE123C", lineHeight: 1.5, textAlign: "left" }}>{error}</div>}
              <button type="submit" data-testid="signup-send-code" className="btn btn-primary" disabled={!emailValid || busy} style={{ width: "100%", justifyContent: "center", opacity: (!emailValid || busy) ? 0.55 : 1 }}>
                {busy ? t("SignUpOverlay.sendBusy") : <>{t("SignUpOverlay.sendLabel")} <Ic.arrow width="14" height="14" /></>}
              </button>
            </form>
          </>
        ) : (
          <>
            <div>
              <div style={{ fontSize: 19, fontWeight: 700, letterSpacing: "-0.02em", marginBottom: 8 }}>
                {t("SignUpOverlay.codeHeading")}
              </div>
              <p style={{ margin: 0, fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.6 }}>
                {t("SignUpOverlay.codeSentPre")} <b style={{ color: "var(--ink)" }}>{email.trim()}</b>. {t("SignUpOverlay.codeSentPost")}
              </p>
            </div>
            <form onSubmit={(e) => { e.preventDefault(); verify(); }} style={{ display: "flex", flexDirection: "column", gap: 12, width: "100%" }}>
              <input
                inputMode="numeric"
                autoComplete="one-time-code"
                placeholder={t("SignUpOverlay.codePlaceholder")}
                value={code}
                onChange={(e) => { setCode(e.target.value.replace(/\D/g, "").slice(0, 6)); setError(null); }}
                maxLength={6}
                disabled={busy}
                style={{ ...OK_INPUT_STYLE, textAlign: "center", fontFamily: "var(--mono)", fontSize: 20, letterSpacing: "0.4em", fontWeight: 600 }}
              />
              {error && <div style={{ fontSize: 12.5, color: "#BE123C", lineHeight: 1.5, textAlign: "left" }}>{error}</div>}
              <button type="submit" data-testid="signup-verify" className="btn btn-primary" disabled={!codeValid || busy} style={{ width: "100%", justifyContent: "center", opacity: (!codeValid || busy) ? 0.55 : 1 }}>
                {busy ? t("SignUpOverlay.verifyBusy") : <>{t("SignUpOverlay.verifyLabel")} <Ic.arrow width="14" height="14" /></>}
              </button>
              <button
                type="button"
                onClick={() => { setStep("email"); setCode(""); setError(null); }}
                disabled={busy}
                style={{ appearance: "none", background: "none", border: "none", cursor: "pointer", fontSize: 12.5, color: "var(--mute)", textDecoration: "underline", textUnderlineOffset: 2 }}
              >
                {t("SignUpOverlay.useDifferentEmail")}
              </button>
            </form>
          </>
        )}
      </div>
    </div>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// FULL REPORT

// Map the API's RepairDifficulty enum to the report's pill color classes.
const DIFFICULTY_COLOR = { Easy: "accent", Medium: "warn", Hard: "danger" };

const InspectionReport = ({ severity, onHome, uploadedPhoto, inspection, inspectionId }) => {
  // Pre-unlocked if this visitor already verified their email for this inspection
  // (an access token is persisted client-side) — don't show the gate again.
  const [signedUp, setSignedUp] = React.useState(() => !!OK_API.getInspectionToken(inspectionId));

  // Postcode for the nearby-partner search. Seeded from the inspection's own zipCode
  // (already loaded before this renders); editing it re-runs the partner lookups.
  const [postcode, setPostcode] = React.useState(() => (inspection && inspection.zipCode) || "20354");

  // This report renders only for a real inspection. Guard the rare double-fetch-failure
  // path (valid id but no payload) rather than rendering an empty report.
  // Collapse every { en, de } field to the active language once, here at the top, so the whole
  // report tree below renders plain strings. Re-runs on language toggle (getLang() in deps).
  const ins = React.useMemo(() => (inspection ? localizeDeep(inspection) : null), [inspection, getLang()]);
  if (!ins) {
    return <ResultMessage tone="error" title={t("InspectionReport.reportLoadError")} message={t("InspectionReport.reportLoadErrorBody")} actionLabel={t("ResultMessage.errorAction")} onAction={onHome} />;
  }

  // Verdict photo — the real uploaded inspection photo (first inspectionImage) when we have
  // one. This works on deep-link/refresh, where the in-memory File is gone. The photo routes
  // are public, so an <img src> needs no auth. Falls back to the File, then the synthetic car.
  const insImage = (Array.isArray(ins.inspectionImages) ? ins.inspectionImages : []).find((im) => im && im.image);
  const insImageUrl = insImage ? OK_API.inspectionPhotoUrl(insImage.image) : null;
  const eur = (n) => `€${Math.round(n).toLocaleString("de-DE")}`;

  // DIY repair guide — one card per real damage carrying DIY data (any of materials /
  // instructions / checks).
  const damages = Array.isArray(ins.damages) ? ins.damages : [];
  const diyDamages = damages.filter((d) =>
    d && d.diy && ((d.diy.materials && d.diy.materials.length) || (d.diy.instructions && d.diy.instructions.length) || (d.diy.checks && d.diy.checks.length))
  );
  // Hard repairs are workshop-only — never surface a DIY guide for them, even if a damage
  // carries DIY data.
  const isHardRepair = ins.difficulty === "Hard";
  const showDIY = diyDamages.length > 0 && !isHardRepair;

  // Cost breakdown — summed repair operations (when the inspection carries them).
  const breakdown = normalizeBreakdownFromInspection(ins, damages);
  const useRealBreakdown = !!(breakdown && breakdown.hasOps);

  // Our recommendation — real Workshop/DIY boxes.
  const rec = normalizeRecommendationFromInspection(ins, damages);

  let totalRange, pillText, pillColor, description;
  {
    const { estimatedCostMin: lo, estimatedCostMax: hi, estimatedCost: one } = ins;
    if (lo != null && hi != null && Math.round(lo) !== Math.round(hi)) totalRange = `${eur(lo)} – ${eur(hi)}`;
    else if (lo != null || hi != null || one != null) totalRange = eur(lo != null ? lo : hi != null ? hi : one);
    else totalRange = "—";
    // The pill shows the full "<difficulty> repair" phrasing (EN "Hard repair" / DE "Schwierige
    // Reparatur"), which lives in the client string catalog — richer than the bare server enum
    // label ("Hard"/"Schwer"). Keyed off the raw enum value; colour likewise uses the raw value.
    pillText = ins.difficulty ? t("InspectionReport.difficultyPill_" + ins.difficulty) : null;
    pillColor = DIFFICULTY_COLOR[ins.difficulty] || "accent";
    description = L(ins.estimateDescription) || null;
  }

  const verdictBody = (
    <>
      <div className="mono num" style={{ fontSize: "clamp(40px, 5.4vw, 68px)", fontWeight: 500, letterSpacing: "-0.03em", lineHeight: 1, whiteSpace: "nowrap" }}>
        {totalRange}
      </div>
      {pillText && (
        <span className={`pill pill-${pillColor}`} style={{ alignSelf: "flex-start" }}>
          <span className="dot" /> {pillText}
        </span>
      )}
      {description && (
        <p style={{ fontSize: 16.5, color: "var(--ink-2)", textWrap: "pretty", margin: 0, lineHeight: 1.55 }}>
          {description}
        </p>
      )}
    </>
  );

  return (
    <>
      {/* Back nav */}
      <div className="wrap" style={{ paddingTop: 64, paddingBottom: 4 }}>
        <button onClick={onHome} className="btn btn-ghost btn-sm" style={{ paddingLeft: 12 }}>
          <Ic.arrow width="14" height="14" style={{ transform: "rotate(180deg)" }} /> {t("InspectionReport.back")}
        </button>
      </div>

      {/* Verdict — big number + photo */}
      <section style={{ padding: "48px 0 32px" }}>
        <div className="wrap verdict-grid" style={{ display: "grid", gridTemplateColumns: "minmax(0,1fr) minmax(0,.7fr)", gap: 64, alignItems: "center" }}>
          <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
            <div className="eyebrow">{t("InspectionReport.costEyebrow")}</div>
            {verdictBody}
            <div style={{ display: "flex", gap: 10, alignItems: "flex-start", padding: "14px 16px", border: "1px solid var(--rule)", borderRadius: 10, background: "var(--paper)", marginTop: 4 }}>
              <span style={{
                width: 18, height: 18, borderRadius: 50,
                background: "var(--mute)", color: "#fff",
                display: "grid", placeItems: "center", flexShrink: 0, marginTop: 1,
                fontSize: 12, fontWeight: 700, fontFamily: "var(--sans)",
                lineHeight: 1,
              }}>!</span>
              <p style={{ margin: 0, fontSize: 12.5, color: "var(--mute)", lineHeight: 1.5 }}>
                {t("InspectionReport.aiDisclaimer")}
              </p>
            </div>
          </div>
          <div>
            {insImageUrl
              ? <UploadedPhoto src={insImageUrl} />
              : uploadedPhoto
                ? <UploadedPhoto file={uploadedPhoto} />
                : <CarPhoto severity={severity} />}
          </div>
        </div>
        <style>{`@media (max-width:880px){.verdict-grid{grid-template-columns:1fr !important; gap:40px !important}}`}</style>
      </section>

      {/* Recommendation → Cost breakdown → Workshops → Gutachter */}
      <section style={{ padding: "32px 0 64px" }}>
        <div className="wrap" style={{ display: "flex", flexDirection: "column", gap: 48 }}>
          <div>
            <div className="eyebrow" style={{ marginBottom: 14 }}>{t("InspectionReport.recommendationEyebrow")}</div>
            <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
              <RealWorkshopCard turnaround={rec.turnaround} repairSummary={rec.repairSummary} recommended={rec.proRecommended} />
              {rec.showDIYBox && (
                <RealDIYCard
                  materialsCost={rec.materialsCost}
                  difficulty={rec.difficulty}
                  difficultyLabel={rec.difficultyLabel}
                  activeTime={rec.activeTime}
                  itemCount={rec.itemCount}
                  diySummary={rec.diySummary}
                  recommended={!rec.proRecommended}
                />
              )}
            </div>
          </div>
          {/* Gated content — real email gate layered over the report sections */}
          <div style={{ position: "relative" }}>
            {/* Blurred content */}
            <div style={{ display: "flex", flexDirection: "column", gap: 48, filter: signedUp ? "none" : "blur(6px)", pointerEvents: signedUp ? "auto" : "none", userSelect: signedUp ? "auto" : "none", transition: "filter .4s ease" }}>
              {/* DIY repair guide — one card per real damage carrying DIY data */}
              {showDIY && (
                <div>
                  <div className="eyebrow" style={{ marginBottom: 14 }}>{t("InspectionReport.diyEyebrow")}</div>
                  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
                    {diyDamages.map((d, i) => (
                      <DIYDamageGuide key={d.id || i} guide={normalizeDIYFromDamage(d)} />
                    ))}
                  </div>
                </div>
              )}
              <div>
                <div className="eyebrow" style={{ marginBottom: 14 }}>{t("InspectionReport.breakdownEyebrow")}</div>
                {useRealBreakdown
                  ? <BreakdownCard categories={breakdown.categories} totalRange={breakdown.totalRange} />
                  : <div style={{ padding: "20px 0", fontSize: 13.5, color: "var(--mute)" }}>{t("InspectionReport.breakdownUnavailable")}</div>}
                {/* One-time Stripe purchase — buy → Checkout, paid → stream the PDF */}
                <CostBreakdownPdf inspectionId={inspectionId} signedUp={signedUp} />
              </div>
              <WorkshopsSection inspectionId={inspectionId} signedUp={signedUp} postcode={postcode} onPostcodeChange={setPostcode} />
              <div>
                <div className="eyebrow" style={{ marginBottom: 14 }}>{t("InspectionReport.gutachterEyebrow")}</div>
                <GutachterSection costMin={ins.estimatedCostMin} costMax={ins.estimatedCostMax} inspectionId={inspectionId} signedUp={signedUp} postcode={postcode} onPostcodeChange={setPostcode} />
              </div>
              <WorkshopsSection inspectionId={inspectionId} signedUp={signedUp} postcode={postcode} onPostcodeChange={setPostcode} />
            </div>
            {!signedUp && <SignUpOverlay inspectionId={inspectionId} onUnlocked={() => setSignedUp(true)} />}
          </div>
        </div>
      </section>

      <Footer />
    </>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// BREAKDOWN

// Cost breakdown — Parts & paint = Σ material costs, Labor = Σ hours × rate, across all
// damages' repair operations. Total estimate is the inspection's estimatedCost range.
const normalizeBreakdownFromInspection = (ins, damages) => {
  const allOps = damages.flatMap((d) => (Array.isArray(d.repairOperations) ? d.repairOperations : []));
  const partPaint = allOps.reduce((a, op) => a + (op.materials || 0), 0);
  const labor = allOps.reduce((a, op) => a + (op.hours || 0) * (op.rate || 0), 0);
  const fmt = (n) => `€${Math.round(n || 0).toLocaleString("de-DE")}`;
  const lo = ins && ins.estimatedCostMin, hi = ins && ins.estimatedCostMax;
  let totalRange;
  if (lo != null && hi != null && Math.round(lo) !== Math.round(hi)) totalRange = `${fmt(lo)} – ${fmt(hi)}`;
  else if (lo != null || hi != null) totalRange = fmt(lo != null ? lo : hi);
  else totalRange = fmt(partPaint + labor);
  return {
    categories: [
      { label: t("BreakdownCard.catPartsPaint"), sub: t("BreakdownCard.catPartsPaintSub"), amount: Math.round(partPaint) },
      { label: t("BreakdownCard.catLabor"), sub: t("BreakdownCard.catLaborSub"), amount: Math.round(labor) },
    ],
    totalRange,
    hasOps: allOps.length > 0,
  };
};

const BreakdownCard = ({ categories, totalRange }) => {
  const cats = categories || [];
  const catTotal = cats.reduce((a, c) => a + c.amount, 0);
  return (
    <div className="card cb-card" style={{ padding: 28 }}>
      <div className="cb-cats" style={{ display: "grid", gridTemplateColumns: `repeat(${cats.length}, minmax(0,1fr))`, gap: 1, background: "var(--rule)", borderRadius: 10, overflow: "hidden", marginBottom: 20 }}>
        {cats.map((cat, i) => {
          const pct = catTotal > 0 ? Math.round((cat.amount / catTotal) * 100) : 0;
          return (
            <div key={i} style={{ background: "var(--paper)", padding: "18px 16px", display: "flex", flexDirection: "column", gap: 10, minWidth: 0 }}>
              <div className="tiny">{cat.label}</div>
              <div className="mono num" style={{ fontSize: 22, fontWeight: 500, letterSpacing: "-0.02em", lineHeight: 1, whiteSpace: "nowrap" }}>
                €{cat.amount.toLocaleString("de-DE")}
              </div>
              <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
                <span style={{
                  fontSize: 11, fontWeight: 700, color: "var(--accent-ink)",
                  background: "var(--accent-tint)", borderRadius: 20,
                  padding: "2px 7px", letterSpacing: "0.01em",
                }}>{pct}%</span>
                <span className="tiny">{cat.sub}</span>
              </div>
            </div>
          );
        })}
      </div>

      <div className="cb-total" style={{ display: "grid", gridTemplateColumns: "1fr auto", padding: "18px 0 0", borderTop: "1.5px solid var(--ink)", alignItems: "baseline", gap: 8 }}>
        <div>
          <div style={{ fontSize: 15, fontWeight: 600 }}>{t("BreakdownCard.totalLabel")}</div>
          <div className="tiny" style={{ marginTop: 3 }}>{t("BreakdownCard.totalSub")}</div>
        </div>
        <div className="mono num" style={{ fontSize: 24, fontWeight: 500, letterSpacing: "-0.015em", whiteSpace: "nowrap" }}>{totalRange}</div>
      </div>

      {/* Mobile: stack the two category cards and the total row so wide currency
          ranges never overflow. minmax(0,1fr) + minWidth:0 keep cells shrinkable. */}
      <style>{`@media (max-width:560px){
        .cb-card{padding:20px !important}
        .cb-cats{grid-template-columns:1fr !important}
        .cb-total{grid-template-columns:1fr !important;align-items:start !important;gap:4px !important}
        .cb-total .num{font-size:21px !important}
      }`}</style>
    </div>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// COST-BREAKDOWN PDF (one-time Stripe purchase)

// COST_BREAKDOWN_DESC and COST_BREAKDOWN_DISCLAIMER are now sourced from the i18n catalog
// via t("CostPdf.shellDesc") and t("CostPdf.shellDisclaimer") in CostBreakdownShell.

const CURRENCY_SYMBOL = { EUR: "€", USD: "$", GBP: "£" };
const formatPrice = (cents, currency) => {
  const sym = CURRENCY_SYMBOL[currency] || (currency ? currency + " " : "");
  return `${sym}${((Number(cents) || 0) / 100).toFixed(2)}`;
};

// The static card shell — title + description, a caller-supplied action area, an optional
// note line, then the disclaimer.
const CostBreakdownShell = ({ action, note, style }) => (
  <div className="card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16, ...style }}>
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      <div style={{ fontSize: 16, fontWeight: 600, letterSpacing: "-0.01em" }}>{t("CostPdf.shellTitle")}</div>
      <p style={{ margin: 0, fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.6 }}>{t("CostPdf.shellDesc")}</p>
    </div>
    <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>{action}</div>
    {note}
    <div style={{ display: "flex", gap: 10, alignItems: "flex-start", paddingTop: 14, borderTop: "1px dashed var(--rule)" }}>
      <span style={{
        width: 18, height: 18, borderRadius: 50,
        background: "var(--mute)", color: "#fff",
        display: "grid", placeItems: "center", flexShrink: 0, marginTop: 1,
        fontSize: 12, fontWeight: 700, fontFamily: "var(--sans)", lineHeight: 1,
      }}>!</span>
      <p style={{ margin: 0, fontSize: 12, color: "var(--mute)", lineHeight: 1.55 }}>{t("CostPdf.shellDisclaimer")}</p>
    </div>
  </div>
);

// "Download detailed cost breakdown" — a one-time Stripe purchase wired to
// /v1/inspections/{id}/cost-breakdown{,/checkout,/pdf}. Auth-gated, so only once the email gate
// is unlocked we fetch status: buy button → Stripe Checkout (redirect away, return restores
// state from the deep-linkable /inspection/<id> URL); paid → stream the PDF as a blob. A
// sessionStorage flag set before the redirect makes us poll on return so the button flips to
// "download" even with Stripe webhook lag.
const CostBreakdownPdf = ({ inspectionId, signedUp }) => {
  const pendingKey = inspectionId ? `ok_cb_checkout:${inspectionId}` : null;

  const [status, setStatus] = React.useState(null);   // null = loading; else { paid, available, priceCents, currency }
  const [error, setError] = React.useState(null);
  const [busy, setBusy] = React.useState(false);       // checkout redirect / pdf download in flight

  React.useEffect(() => {
    if (!inspectionId || !signedUp) return;
    let alive = true;
    let timer = null;
    let tries = 0;
    const pending = !!(pendingKey && sessionStorage.getItem(pendingKey));

    const tick = async () => {
      try {
        const s = await OK_API.getCostBreakdownStatus(inspectionId);
        if (!alive) return;
        setStatus(s); setError(null);
        if (s && s.paid) { if (pendingKey) sessionStorage.removeItem(pendingKey); return; }
      } catch (e) {
        if (!alive) return;
        setError(e);
      }
      // Keep polling only while we're waiting on a just-started payment (webhook lag).
      if (alive && pending && tries < 20) { tries++; timer = setTimeout(tick, 3000); }
      else if (pending && pendingKey) sessionStorage.removeItem(pendingKey);  // gave up waiting
    };

    setStatus(null);
    tick();
    return () => { alive = false; if (timer) clearTimeout(timer); };
  }, [inspectionId, signedUp, pendingKey]);

  const refetch = () => {
    OK_API.getCostBreakdownStatus(inspectionId)
      .then((s) => { setStatus(s); setError(null); })
      .catch((e) => setError(e));
  };

  // Buy → create a Stripe Checkout Session and hand off to its hosted page. We don't clear
  // `busy` on success because the page navigates away; the pending flag resumes polling on return.
  const buy = async () => {
    if (busy) return;
    setBusy(true); setError(null);
    try {
      const r = await OK_API.createCostBreakdownCheckout(inspectionId);
      const url = OK_API.sessionUrl(r);
      if (!url) throw new Error("No checkout URL returned.");
      if (pendingKey) sessionStorage.setItem(pendingKey, "1");
      window.location.assign(url);
    } catch (e) {
      if (e.status === 409) { setBusy(false); refetch(); return; }   // already paid → flip to download
      setError(e); setBusy(false);
    }
  };

  // Paid → stream the PDF and trigger a browser download.
  const download = async () => {
    if (busy) return;
    setBusy(true); setError(null);
    try {
      const blob = await OK_API.getCostBreakdownPdf(inspectionId);
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `ottoklar-cost-breakdown-${inspectionId}.pdf`;
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
    } catch (e) {
      if (e.status === 403) refetch();   // not actually paid (lag) → resync
      setError(e);
    } finally {
      setBusy(false);
    }
  };

  // Nothing to sell yet (no estimate) and not already bought → hide the section entirely.
  if (status && status.available === false && !status.paid) return null;

  const errNote = (msg) => <div style={{ fontSize: 12, color: "#BE123C" }}>{msg}</div>;

  let action, note;
  if (status === null) {
    action = <button className="btn btn-accent" disabled style={{ opacity: 0.6, cursor: "default" }}>{t("CostPdf.loadingBtn")}</button>;
    if (error) note = errNote(t("CostPdf.loadError"));
  } else if (status.paid) {
    action = (
      <button className="btn btn-accent" data-testid="pdf-download" disabled={busy} onClick={download}
        style={{ opacity: busy ? 0.6 : 1, cursor: busy ? "default" : "pointer" }}>
        {busy ? t("CostPdf.preparingBtn") : <><Ic.arrowDown width="14" height="14" /> {t("CostPdf.downloadBtn")}</>}
      </button>
    );
    note = (
      <div style={{ fontSize: 12, color: "var(--accent-ink)", display: "flex", alignItems: "center", gap: 4 }}>
        <Ic.check width="12" height="12" /> {t("CostPdf.purchased")}
      </div>
    );
    if (error) note = errNote(t("CostPdf.downloadError"));
  } else {
    action = (
      <button className="btn btn-accent" data-testid="pdf-buy" disabled={busy} onClick={buy}
        style={{ opacity: busy ? 0.6 : 1, cursor: busy ? "default" : "pointer" }}>
        {busy ? t("CostPdf.redirectingBtn") : <>{t("CostPdf.buyBtn")} <span style={{ opacity: 0.75, fontWeight: 400 }}>· {formatPrice(status.priceCents, status.currency)}</span></>}
      </button>
    );
    if (error) note = errNote(error.status === 400
      ? t("CostPdf.notReadyError")
      : t("CostPdf.paymentError"));
  }

  return <CostBreakdownShell action={action} note={note} style={{ marginTop: 16 }} />;
};

// ─────────────────────────────────────────────────────────────────────────────
// Recommendation cards — built from the live inspection. Both take primitive props
// and drop any stat/text whose value is null, so partial inspections render cleanly.

const RealWorkshopCard = ({ turnaround, repairSummary, recommended }) => (
  <div className="card" style={{
    padding: 24, display: "flex", flexDirection: "column", gap: 16,
    borderColor: recommended ? "var(--ink)" : "var(--rule)",
    borderWidth: recommended ? 1.5 : 1,
    boxShadow: recommended ? "var(--shadow-md)" : "var(--shadow-sm)",
  }}>
    <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
        <div style={{ width: 44, height: 44, borderRadius: 12, background: "var(--accent-tint)", color: "var(--accent-ink)", display: "grid", placeItems: "center", flexShrink: 0 }}>
          <Ic.wrench2 width="20" height="20" />
        </div>
        <div style={{ fontSize: 17, fontWeight: 600, letterSpacing: "-0.01em" }}>{t("RealWorkshopCard.heading")}</div>
      </div>
      {recommended && (
        <span className="pill pill-accent" style={{ flexShrink: 0 }}>
          <Ic.check width="12" height="12" /> {t("RealWorkshopCard.recommended")}
        </span>
      )}
    </div>

    {turnaround && (
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, padding: "16px 0", borderTop: "1px dashed var(--rule)", borderBottom: "1px dashed var(--rule)" }}>
        <RecStat label={t("RealWorkshopCard.turnaroundLabel")} value={turnaround} />
      </div>
    )}

    {repairSummary && (
      <div style={{ fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.5 }}>{repairSummary}</div>
    )}
  </div>
);

// Map the API difficulty enum → DIY bar-meter (filled bars + word).
const DIFFICULTY_METER = { Easy: [1, "Beginner"], Medium: [3, "Intermediate"], Hard: [5, "Advanced"] };

const RealDIYCard = ({ materialsCost, difficulty, difficultyLabel, activeTime, itemCount, diySummary, recommended }) => {
  const hasMaterials = itemCount > 0;
  const meter = (difficulty && DIFFICULTY_METER[difficulty]) || null; // RAW enum drives the bar count
  // Display label: prefer the server-provided localized label, fall back to the client catalog.
  // DIY skill level uses the client catalog's wording (EN "Advanced" / DE "Experte"), not the bare
  // repair-difficulty enum label ("Hard"/"Schwer"). Keyed off the raw enum value.
  const difficultyText = difficulty ? t("RealDIYCard.difficulty_" + difficulty) : "";
  return (
    <div className="card" style={{
      padding: 26, display: "flex", flexDirection: "column", gap: 20,
      borderColor: recommended ? "var(--ink)" : "var(--rule)",
      borderWidth: recommended ? 1.5 : 1,
      boxShadow: recommended ? "var(--shadow-md)" : "var(--shadow-sm)",
    }}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
          <div style={{ width: 44, height: 44, borderRadius: 12, background: "var(--accent-tint)", color: "var(--accent-ink)", display: "grid", placeItems: "center", flexShrink: 0 }}>
            <Ic.bolt width="20" height="20" />
          </div>
          <div style={{ fontSize: 17, fontWeight: 600, letterSpacing: "-0.01em" }}>{t("RealDIYCard.heading")}</div>
        </div>
        {recommended && (
          <span className="pill pill-accent" style={{ flexShrink: 0 }}>
            <Ic.check width="12" height="12" /> {t("RealDIYCard.recommended")}
          </span>
        )}
      </div>

      <div style={{ display: "flex", gap: 48, flexWrap: "wrap", padding: "16px 0", borderTop: "1px dashed var(--rule)", borderBottom: "1px dashed var(--rule)" }}>
        {hasMaterials && (
          <div>
            <div className="tiny" style={{ marginBottom: 5 }}>{t("RealDIYCard.materialsCostLabel")}</div>
            <div className="mono num" style={{ fontSize: 13.5, fontWeight: 600 }}>{diyEur(materialsCost)}</div>
          </div>
        )}
        {meter && (
          <div>
            <div className="tiny" style={{ marginBottom: 5 }}>{t("RealDIYCard.difficultyLabel")}</div>
            <div style={{ fontSize: 13.5, fontWeight: 500, display: "inline-flex", alignItems: "center", gap: 6 }}>
              <span style={{ display: "inline-flex", gap: 3 }}>
                {[1, 2, 3, 4, 5].map((i) => (
                  <span key={i} style={{ width: 6, height: 14, borderRadius: 2, background: i <= meter[0] ? "var(--accent)" : "var(--rule-2)" }} />
                ))}
              </span>
              <span style={{ marginLeft: 4 }}>{difficultyText}</span>
            </div>
          </div>
        )}
        {activeTime && (
          <div>
            <div className="tiny" style={{ marginBottom: 5 }}>{t("RealDIYCard.activeTimeLabel")}</div>
            <div className="mono num" style={{ fontSize: 13.5, fontWeight: 500 }}>{activeTime}</div>
          </div>
        )}
        {hasMaterials && (
          <div>
            <div className="tiny" style={{ marginBottom: 5 }}>{t("RealDIYCard.materialsLabel")}</div>
            <div style={{ fontSize: 13.5, fontWeight: 500 }}>{tn("RealDIYCard.items", itemCount)}</div>
          </div>
        )}
      </div>

      {diySummary && (
        <div style={{ fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.5 }}>{diySummary}</div>
      )}
    </div>
  );
};

// Format a number as a 2-decimal euro amount (€11.90). Matches the existing DIY card.
const diyEur = (n) => `€${Number(n || 0).toFixed(2)}`;

// Workshop turnaround — durationMinutes (labor minutes) → full working days, ceiled.
// A working day is 8h (480 min), so 600 min → 2 days. Null/non-positive → null (stat hidden).
const WORKDAY_MIN = 480;
const turnaroundDays = (durationMinutes) => {
  if (durationMinutes == null || !Number.isFinite(durationMinutes) || durationMinutes <= 0) return null;
  const d = Math.ceil(durationMinutes / WORKDAY_MIN);
  return tn("TurnaroundDays.days", d);
};

// DIY active time — totalDiyActiveMinutes humanized, prefixed with "~" when rounding occurs.
//  < 1h : "MM min" (no rounding)
//  < 1d : nearest half hour → "1h" / "1h 30m"  (65→~1h, 85→~1h 30m)
//  ≥ 1d : nearest full hour → "Dd" / "Dd Hh"    (1440→1d, 1500→~1d 1h)
// The band is re-derived from the rounded minute count so 1439 → "1d", not "24h".
const activeTimeLabel = (totalMinutes) => {
  if (totalMinutes == null || !Number.isFinite(totalMinutes) || totalMinutes <= 0) return null;
  // Sub-hour: exact minutes, never rounded.
  if (totalMinutes < 60) return t("ActiveTimeLabel.minutes", { count: Math.round(totalMinutes) });
  // Choose rounding granularity by magnitude, then re-derive the band from the result.
  const grain = totalMinutes < 1440 ? 30 : 60;
  const shown = Math.round(totalMinutes / grain) * grain;
  const tilde = shown !== totalMinutes ? "~" : "";
  if (shown < 1440) {
    const h = Math.floor(shown / 60), rem = shown % 60;
    return `${tilde}${h}h${rem ? " 30m" : ""}`;
  }
  const totalH = shown / 60, d = Math.floor(totalH / 24), hh = totalH % 24;
  return `${tilde}${d}d${hh ? ` ${hh}h` : ""}`;
};

// "Our recommendation" — real inspection → Workshop + (conditionally) DIY box data.
// Difficulty drives both: when difficulty is Hard ("high") we show only the Workshop
// box and recommend it; otherwise we show both boxes and recommend DIY.
const normalizeRecommendationFromInspection = (ins, damages) => {
  const proRecommended = ins.difficulty === "Hard";
  const showDIYBox = !proRecommended;

  const allMaterials = damages.flatMap((d) =>
    (d && d.diy && Array.isArray(d.diy.materials)) ? d.diy.materials : []
  );
  const materialsCost = allMaterials.reduce((a, m) => a + (Number(m.quantity) || 0) * (Number(m.unitPrice) || 0), 0);
  const itemCount = new Set(
    allMaterials.map((m) => (L(m.name) || "").trim().toLowerCase()).filter(Boolean)
  ).size;

  return {
    showDIYBox,
    proRecommended,
    turnaround: turnaroundDays(ins.durationMinutes),
    repairSummary: L(ins.repairDescription) || null,
    materialsCost,
    difficulty: ins.difficulty || null,              // RAW enum — drives the difficulty meter/logic
    difficultyLabel: ins.difficultyLabel || null,    // localized display label ({en,de})
    activeTime: activeTimeLabel(ins.totalDiyActiveMinutes),
    itemCount,
    diySummary: L(ins.diyDescription) || null,
  };
};

// Normalize a real damage's `diy` payload into the one shape DIYDamageGuide renders.
// Materials and instructions are sorted by their sortOrder; `price` is the line total.
const normalizeDIYFromDamage = (d) => {
  const diy = (d && d.diy) || {};
  const materials = [...(diy.materials || [])]
    .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
    // name is free text ({en,de}); localize it here so the card renders a plain string.
    .map((m) => ({ name: L(m.name), quantity: m.quantity != null ? m.quantity : 1, unitPrice: m.unitPrice || 0, total: m.price || 0 }));
  return {
    // Title pieces are enums — display their localized *Label (falls back to the raw enum
    // value via L() for legacy payloads that predate the labels).
    title: [
      d && (L(d.locationLabel) || d.location),
      d && (L(d.partLabel) || d.part),
      d && (L(d.damageTypeLabel) || d.damageType),
    ].filter(Boolean).join(" · "),
    materials,
    materialsTotal: materials.reduce((a, m) => a + (m.total || 0), 0),
    // instruction text and check/diyIf/workshopIf are free text ({en,de}); localize now.
    steps: [...(diy.instructions || [])].sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)).map((i) => L(i.text)),
    checks: (diy.checks || []).map((c) => ({
      check: L(c.check),
      diyIf: L(c.diyIf),
      workshopIf: L(c.workshopIf),
    })),
  };
};

// "Check before you start" hint — blue box mirroring the partner dashboard's
// 48-hour-window notice. Each check pairs a DIY-if and a Workshop-instead-if condition.
const DIYCheckBox = ({ checks, trailing }) => (
  <div style={{
    display: "flex", alignItems: "flex-start", gap: 11,
    background: "#EFF6FF", border: "1px solid rgba(29,78,216,.12)",
    borderRadius: 12, padding: "12px 16px", marginBottom: trailing ? 14 : 0,
  }}>
    <svg viewBox="0 0 16 16" fill="none" stroke="#3B82F6" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" width="15" height="15" style={{ flexShrink: 0, marginTop: 2 }}>
      <circle cx="8" cy="8" r="6.5" />
      <path d="M8 7.5v3M8 5.2v.3" />
    </svg>
    <div style={{ fontSize: 13.5, color: "#1D4ED8", lineHeight: 1.6 }}>
      <strong>{t("DIYCheckBox.heading")}</strong>
      <div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 8 }}>
        {checks.map((c, i) => (
          <div key={i}>
            {c.check && <div style={{ fontWeight: 600 }}>{c.check}</div>}
            {c.diyIf && <div style={{ color: "var(--accent-ink)", marginTop: 3 }}><strong>{t("DIYCheckBox.diyIf")}</strong> {c.diyIf}</div>}
            {c.workshopIf && <div style={{ color: "#B45309", marginTop: 3 }}><strong>{t("DIYCheckBox.workshopIf")}</strong> {c.workshopIf}</div>}
          </div>
        ))}
      </div>
    </div>
  </div>
);

// One damage's DIY guide. Each block guards on its own data: a missing materials /
// instructions / checks section is simply dropped (the card itself is not).
const DIYDamageGuide = ({ guide }) => {
  const hasMaterials = !!(guide.materials && guide.materials.length);
  const hasSteps = !!(guide.steps && guide.steps.length);
  const hasChecks = !!(guide.checks && guide.checks.length);
  return (
    <div className="card" style={{ padding: 26, display: "flex", flexDirection: "column", gap: 20 }}>
      {/* Title */}
      {guide.title && (
        <div style={{ fontSize: 20, fontWeight: 600, letterSpacing: "-0.02em" }}>
          {guide.title}
        </div>
      )}

      {/* Materials checklist — name · qty × unit · line total, with a subtotal */}
      {hasMaterials && (
        <div>
          <div className="eyebrow" style={{ marginBottom: 10 }}>{t("DIYGuide.materialsEyebrow")}</div>
          <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
            {guide.materials.map((m, i) => (
              <div key={i} style={{ display: "grid", gridTemplateColumns: "auto 1fr auto auto", gap: 12, alignItems: "center" }}>
                <span style={{
                  width: 26, height: 26, borderRadius: 6, background: "var(--bg-2)",
                  color: "var(--ink-2)", display: "grid", placeItems: "center", flexShrink: 0,
                }}>
                  <Ic.check width="13" height="13" />
                </span>
                <div style={{ fontSize: 13.5, fontWeight: 500 }}>{m.name}</div>
                <div className="mono" style={{ fontSize: 12.5, color: "var(--mute)", whiteSpace: "nowrap" }}>{m.quantity} × {diyEur(m.unitPrice)}</div>
                <div className="mono num" style={{ fontSize: 13.5, fontWeight: 600, whiteSpace: "nowrap" }}>{diyEur(m.total)}</div>
              </div>
            ))}
          </div>
          <div style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: 12, alignItems: "baseline", marginTop: 12, paddingTop: 12, borderTop: "1px dashed var(--rule)" }}>
            <div style={{ fontSize: 13.5, fontWeight: 600 }}>{t("DIYGuide.totalMaterials")}</div>
            <div className="mono num" style={{ fontSize: 15, fontWeight: 600 }}>{diyEur(guide.materialsTotal)}</div>
          </div>
        </div>
      )}

      {/* Divider between materials and the steps/checks block */}
      {hasMaterials && (hasSteps || hasChecks) && <div style={{ borderTop: "1px dashed var(--rule)" }} />}

      {/* Step-by-step — check box sits between the title and the numbered steps */}
      {(hasSteps || hasChecks) && (
        <div>
          {hasSteps && <div className="eyebrow" style={{ marginBottom: 10 }}>{t("DIYGuide.stepsEyebrow")}</div>}
          {hasChecks && <DIYCheckBox checks={guide.checks} trailing={hasSteps} />}
          {hasSteps && (
            <ol style={{ padding: 0, margin: 0, listStyle: "none", display: "flex", flexDirection: "column", gap: 10 }}>
              {guide.steps.map((step, i) => (
                <li key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", fontSize: 13.5, lineHeight: 1.5 }}>
                  <span className="mono" style={{ fontSize: 11, color: "var(--mute)", marginTop: 3, minWidth: 16 }}>{String(i + 1).padStart(2, "0")}</span>
                  <span style={{ color: "var(--ink-2)" }}>{step}</span>
                </li>
              ))}
            </ol>
          )}
        </div>
      )}
    </div>
  );
};

const RecStat = ({ label, value, mono }) => (
  <div>
    <div className="tiny" style={{ marginBottom: 4 }}>{label}</div>
    <div className={mono ? "mono num" : ""} style={{ fontSize: 14.5, fontWeight: 500, lineHeight: 1.3 }}>{value}</div>
  </div>
);

// ─────────────────────────────────────────────────────────────────────────────
// NEARBY WORKSHOPS (non-partner)

// Inline-editable ZIP control ("Showing … near <zip>"). Self-contained state; shared across
// the workshop and Gutachter sections.
const ZipPicker = ({ value, onChange, initial = "20354" }) => {
  // Controlled when both value + onChange are supplied (the report's sections all drive the
  // shared postcode); otherwise self-contained, seeded from `initial`.
  const controlled = value !== undefined && typeof onChange === "function";
  const [internal, setInternal] = React.useState(initial);
  const zip = controlled ? value : internal;
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState(zip);

  const commit = () => {
    const v = draft.trim();
    if (v) { controlled ? onChange(v) : setInternal(v); }
    setEditing(false);
  };

  return editing ? (
    <form onSubmit={(e) => { e.preventDefault(); commit(); }} style={{ display: "inline-flex", marginLeft: 6 }}>
      <input
        autoFocus
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onBlur={commit}
        maxLength={10}
        style={{
          fontFamily: "var(--mono)", fontSize: 13, fontWeight: 600,
          color: "var(--ink)", background: "var(--bg)",
          border: "1px solid var(--accent)", borderRadius: 6,
          padding: "2px 8px", width: 90, outline: "none",
        }}
      />
    </form>
  ) : (
    <button onClick={() => { setDraft(zip); setEditing(true); }} style={{
      appearance: "none", background: "none", border: "none", padding: 0,
      marginLeft: 3, fontWeight: 600, color: "var(--ink)", fontSize: 13,
      fontFamily: "inherit", cursor: "default",
      textDecoration: "underline", textDecorationStyle: "dashed", textUnderlineOffset: 3,
    }}>{zip}</button>
  );
};

const NearbyWorkshops = ({ inspectionId, signedUp, postcode, onPostcodeChange }) => {
  // Real independent (non-partner) workshops from GET /v1/inspections/{id}/nearby-workshops
  // — live Google Places results, the fallback shown where no OttoKlar partner covers the
  // region. null = loading; empty = no results found.
  // Auth-gated, so we only fetch once the email gate is unlocked (signedUp) — that's when
  // the per-inspection access token exists. Re-runs when the postcode changes.
  const [shops, setShops] = React.useState(null);
  React.useEffect(() => {
    if (!inspectionId || !signedUp) return;
    let alive = true;
    setShops(null);
    OK_API.getNearbyWorkshops(inspectionId, { postcode, limit: MAX_INDEPENDENT })
      .then((rows) => { if (alive) setShops(Array.isArray(rows) ? rows : []); })
      .catch(() => { if (alive) setShops([]); });
    return () => { alive = false; };
  }, [inspectionId, signedUp, postcode]);

  let rows;
  if (shops === null) {
    rows = partnerStateRow(t("NearbyWorkshops.loading"));
  } else if (shops.length === 0) {
    rows = partnerStateRow(t("NearbyWorkshops.empty"));
  } else {
    rows = shops.slice(0, MAX_INDEPENDENT).map((s, i) => {
      const m = mapNearbyWorkshop(s);
      return (
        <div key={`${s.name}-${i}`} style={{ padding: "20px 0", borderTop: "1px dashed var(--rule)" }}>
          <MatchedShopRow w={m.w} rank={m.rank || i + 1} address={m.address} hideSpecialty hideQuote />
        </div>
      );
    });
  }

  const card = (
    <div className="card" style={{ padding: 28 }}>
      <h3 className="h3" data-testid="workshops-heading" data-variant="independent" style={{ fontSize: 20, marginBottom: 18 }}>{t("NearbyWorkshops.cardHeading")}</h3>
      <div style={{ borderTop: "1px solid var(--rule)" }} />
      <div style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: "var(--mute)", padding: "10px 0 18px" }}>
        <Ic.pin width="13" height="13" style={{ flexShrink: 0 }} />
        {t("NearbyWorkshops.showingNear")}
        <ZipPicker value={postcode} onChange={onPostcodeChange} />
      </div>
      <div style={{
        display: "flex", gap: 12, alignItems: "flex-start",
        padding: "14px 16px", borderRadius: 10,
        background: "var(--accent-tint)", marginBottom: 8,
      }}>
        <Ic.pin width="16" height="16" style={{ color: "var(--accent-ink)", flexShrink: 0, marginTop: 1 }} />
        <div style={{ fontSize: 13.5, color: "var(--accent-ink)", lineHeight: 1.55 }}>
          <b>{t("NearbyWorkshops.comingSoonBold")}</b> {t("NearbyWorkshops.comingSoonBody")}
        </div>
      </div>
      {rows}
    </div>
  );

  return card;
};

// ─────────────────────────────────────────────────────────────────────────────
// MATCHED WORKSHOPS

const VinHelp = () => {
  const [show, setShow] = React.useState(false);
  return (
    <div style={{ position: "relative", display: "inline-flex", alignItems: "center" }}>
      <button
        onMouseEnter={() => setShow(true)}
        onMouseLeave={() => setShow(false)}
        style={{
          appearance: "none", background: "none", border: "none", padding: 0,
          width: 16, height: 16, borderRadius: "50%",
          background: "var(--rule-2)", color: "var(--mute)",
          fontSize: 11, fontWeight: 700, fontFamily: "var(--sans)",
          display: "grid", placeItems: "center", cursor: "default", flexShrink: 0,
        }}
      >?</button>
      {show && (
        <div style={{
          position: "absolute", bottom: "calc(100% + 8px)", left: "50%",
          transform: "translateX(-50%)",
          width: 260, background: "var(--ink)", color: "#fff",
          borderRadius: 10, padding: "12px 14px", zIndex: 99,
          fontSize: 12.5, lineHeight: 1.55,
          boxShadow: "0 4px 16px rgba(0,0,0,.18)",
          pointerEvents: "none",
        }}>
          <b style={{ display: "block", marginBottom: 4 }}>{t("VinHelp.whatTitle")}</b>
          {t("VinHelp.whatBody")}
          <b style={{ display: "block", margin: "8px 0 4px" }}>{t("VinHelp.whereTitle")}</b>
          <ul style={{ margin: 0, paddingLeft: 16 }}>
            <li>{t("VinHelp.li1")}</li>
            <li>{t("VinHelp.li2")}</li>
            <li>{t("VinHelp.li3")}</li>
          </ul>
          <div style={{
            position: "absolute", bottom: -5, left: "50%", transform: "translateX(-50%)",
            width: 10, height: 10, background: "var(--ink)", clipPath: "polygon(0 0,100% 0,50% 100%)",
          }} />
        </div>
      )}
    </div>
  );
};

// We only consider partner workshops/appraisers within this radius "in range"; beyond it we
// fall back to independent listings, of which we show at most MAX_INDEPENDENT.
const MAX_RADIUS_KM = 50;
const MAX_INDEPENDENT = 5;

// Partners (workshops or appraisers) within the service radius. Treats a missing distance as
// out of range so a partner without coordinates never blocks the independent fallback.
const partnersInRange = (partners) =>
  (partners || []).filter((p) => (p.distanceKm == null ? Infinity : p.distanceKm) <= MAX_RADIUS_KM);

// Do these (in-range) partner workshops *collectively* cover all the inspection's required
// services? Each workshop's coveredServices ∪ missingServices is the required set, so a service
// is uncovered iff some workshop lists it as missing and none lists it as covered. With no
// service data at all (missing empty) this is vacuously true — any in-range partner qualifies.
const workshopsFullyCover = (inRange) => {
  const covered = new Set(), missing = new Set();
  inRange.forEach((p) => {
    (p.coveredServices || []).forEach((s) => covered.add(s));
    (p.missingServices || []).forEach((s) => missing.add(s));
  });
  return [...missing].every((s) => covered.has(s));
};

// Map a NearbyPartnerResponse (real API) to the props MatchedShopRow expects.
const mapNearbyPartner = (p) => ({
  id: p.id,
  rank: p.rank,
  specialty: p.specialty,
  address: `${p.street} ${p.houseNumber}, ${p.postcode} ${p.city}`,
  w: {
    name: p.name,
    city: p.city,
    dist: Math.round((p.distanceKm || 0) * 10) / 10,
    rating: p.rating,
    reviews: p.reviewCount,
    lat: p.latitude,
    lng: p.longitude,
  },
});

// Map a NearbyWorkshopResponse (real API) to the props MatchedShopRow expects. These are
// independent Google-Places shops, not OttoKlar partners — no id (so no referral), and the
// address arrives pre-formatted as a single string. googleMapsUri links the real listing.
const mapNearbyWorkshop = (s) => ({
  rank: s.rank,
  address: s.address,
  w: {
    name: s.name,
    dist: Math.round((s.distanceKm || 0) * 10) / 10,
    rating: s.rating,
    reviews: s.reviewCount,
    lat: s.latitude,
    lng: s.longitude,
    mapUri: s.googleMapsUri || null,
  },
});

// Google Maps deep link for a partner — the canonical place URL when one is supplied
// (nearby independent workshops carry a googleMapsUri from Google Places), else a precise
// pin when we have coordinates, else a text search on the name + address.
const partnerMapHref = (w, address) => {
  if (w && w.mapUri) return w.mapUri;
  const q = (w && w.lat != null && w.lng != null)
    ? `${w.lat},${w.lng}`
    : [w && w.name, address || (w && w.city)].filter(Boolean).join(" ");
  return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(q)}`;
};

// Shared empty/loading row styling for the partner lists.
const partnerStateRow = (text) => (
  <div style={{ padding: "20px 0", borderTop: "1px dashed var(--rule)", fontSize: 13.5, color: "var(--mute)" }}>{text}</div>
);

// Workshops section — prefer OttoKlar partners, fall back to independents. We fetch the partner
// list (auth-gated, so only after signedUp), keep those within MAX_RADIUS_KM, and if those
// in-range partners collectively cover all the inspection's required services we show them and
// hide independents. Otherwise the independent (Google-Places) workshops show instead. Renders
// its own eyebrow so the heading matches whichever card is shown.
const WorkshopsSection = ({ inspectionId, signedUp, postcode, onPostcodeChange }) => {
  const [partners, setPartners] = React.useState(null); // null = loading
  React.useEffect(() => {
    if (!inspectionId || !signedUp) return;
    let alive = true;
    setPartners(null);
    OK_API.getInspectionPartners(inspectionId, { type: "Workshop", postcode })
      .then((rows) => { if (alive) setPartners(Array.isArray(rows) ? rows : []); })
      .catch(() => { if (alive) setPartners([]); });
    return () => { alive = false; };
  }, [inspectionId, signedUp, postcode]);

  const eyebrow = (text) => <div className="eyebrow" style={{ marginBottom: 14 }}>{text}</div>;

  if (partners === null) {
    return (
      <div>
        {eyebrow(t("WorkshopsSection.eyebrowNear"))}
        <div className="card" style={{ padding: 28 }}>
          <h3 className="h3" style={{ fontSize: 20, marginBottom: 18 }}>{t("WorkshopsSection.eyebrowNear")}</h3>
          <div style={{ borderTop: "1px solid var(--rule)" }} />
          {partnerStateRow(t("WorkshopsSection.findingWorkshops"))}
        </div>
      </div>
    );
  }

  const inRange = partnersInRange(partners);
  const showPartners = inRange.length > 0 && workshopsFullyCover(inRange);

  return showPartners ? (
    <div>
      {eyebrow(t("WorkshopsSection.eyebrowQuotes"))}
      <MatchedWorkshopsView partners={inRange} inspectionId={inspectionId} postcode={postcode} onPostcodeChange={onPostcodeChange} />
    </div>
  ) : (
    <div>
      {eyebrow(t("WorkshopsSection.eyebrowNear"))}
      <NearbyWorkshops inspectionId={inspectionId} signedUp={signedUp} postcode={postcode} onPostcodeChange={onPostcodeChange} />
    </div>
  );
};

// Presentational partner-workshop card. `partners` is the already-filtered in-range list (always
// ≥1 when this renders). Owns the VIN flow that gates quote requests — VIN is workshop-only.
const MatchedWorkshopsView = ({ partners, inspectionId, postcode, onPostcodeChange }) => {
  const [vin, setVin] = React.useState("");
  const [vinConfirmed, setVinConfirmed] = React.useState(false);
  const [vinBusy, setVinBusy] = React.useState(false);
  const [vinError, setVinError] = React.useState(null);

  const vinValid = vin.trim().length === 17;
  const canConfirmVin = vinValid && !vinConfirmed;

  // Confirm → attach the VIN to the inspection (POST /v1/inspections/{id}/vin).
  const confirmVin = async () => {
    if (!canConfirmVin || vinBusy) return;
    setVinError(null);
    setVinBusy(true);
    try {
      await OK_API.attachInspectionVin(inspectionId, vin.trim());
      setVinConfirmed(true);
    } catch (e) {
      setVinError(e.status === 400
        ? t("MatchedWorkshopsView.vinBadError")
        : (e.message || t("MatchedWorkshopsView.vinSaveError")));
    } finally {
      setVinBusy(false);
    }
  };

  const rows = partners.map((p, i) => {
    const m = mapNearbyPartner(p);
    return (
      <div key={m.id || p.name || i} style={{ padding: "20px 0", borderTop: "1px dashed var(--rule)" }}>
        <MatchedShopRow w={m.w} rank={m.rank || i + 1} specialty={m.specialty} address={m.address} emailValid={vinConfirmed} inspectionId={inspectionId} partnerId={m.id} />
      </div>
    );
  });

  return (
    <div className="card" style={{ padding: 28 }}>
      <h3 className="h3" data-testid="workshops-heading" data-variant="partners" style={{ fontSize: 20, marginBottom: 18 }}>{t("MatchedWorkshopsView.heading")}</h3>
      <div style={{ borderTop: "1px solid var(--rule)" }} />

      {/* Zip line */}
      <div style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: "var(--mute)", padding: "10px 0 18px" }}>
        <Ic.pin width="13" height="13" style={{ flexShrink: 0 }} />
        {t("MatchedWorkshopsView.showingNear")}
        <ZipPicker value={postcode} onChange={onPostcodeChange} />
      </div>

      {/* VIN section */}
      <div style={{ padding: "18px 20px", background: "var(--bg)", borderRadius: 12, marginBottom: 24, display: "flex", flexDirection: "column", gap: 12 }}>
        <p style={{ margin: 0, fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.55 }}>
          <b style={{ color: "var(--ink)" }}>{t("MatchedWorkshopsView.vinPromptBold")}</b><br />
          {t("MatchedWorkshopsView.vinPromptBody")}
        </p>
        <div>
          <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 6 }}>
            <span className="tiny">{t("MatchedWorkshopsView.vinLabel")}</span>
            <VinHelp />
          </div>
          <div style={{ display: "flex", gap: 8 }}>
            <input
              type="text"
              data-testid="vin-input"
              placeholder={t("MatchedWorkshopsView.vinPlaceholder")}
              value={vin}
              onChange={(e) => { setVin(e.target.value.toUpperCase()); setVinConfirmed(false); setVinError(null); }}
              disabled={vinBusy}
              maxLength={17}
              style={{
                flex: 1, fontFamily: "var(--mono)", fontSize: 13,
                color: "var(--ink)", background: "var(--paper)",
                border: `1px solid ${vinConfirmed ? "var(--accent)" : "var(--rule-2)"}`,
                borderRadius: 8, padding: "9px 12px", outline: "none", letterSpacing: "0.04em",
              }}
            />
            <button
              className="btn btn-accent btn-sm"
              data-testid="vin-confirm"
              data-confirmed={vinConfirmed ? "true" : "false"}
              disabled={!canConfirmVin || vinBusy}
              onClick={confirmVin}
              style={{ flexShrink: 0, opacity: (canConfirmVin && !vinBusy) ? 1 : 0.4 }}
            >
              {vinConfirmed ? <><Ic.check width="13" height="13" /> {t("MatchedWorkshopsView.vinConfirmedLabel")}</> : (vinBusy ? t("MatchedWorkshopsView.vinConfirmBusy") : t("MatchedWorkshopsView.vinConfirmIdle"))}
            </button>
          </div>
          {vinError && (
            <div style={{ fontSize: 12, color: "#BE123C", marginTop: 6 }}>{vinError}</div>
          )}
          {vinConfirmed && (
            <div style={{ fontSize: 12, color: "var(--accent-ink)", marginTop: 6, display: "flex", alignItems: "center", gap: 4 }}>
              <Ic.check width="12" height="12" /> {t("MatchedWorkshopsView.vinVerifiedNote")}
            </div>
          )}
          {!vinConfirmed && vin.length > 0 && vin.length < 17 && (
            <div style={{ fontSize: 12, color: "var(--mute)", marginTop: 6 }}>
              {tn("MatchedWorkshopsView.vinCharsRemaining", 17 - vin.length)}
            </div>
          )}
        </div>
      </div>

      {/* Workshop rows */}
      {rows}
    </div>
  );
};

const Stars = ({ value, size = 13 }) => {
  // 0..5 stars, filled / half / empty
  const stars = [];
  for (let i = 1; i <= 5; i++) {
    if (value >= i)        stars.push("full");
    else if (value >= i - 0.5) stars.push("half");
    else                   stars.push("empty");
  }
  return (
    <span style={{ display: "inline-flex", gap: 1, color: "#F5B400" }}>
      {stars.map((kind, i) => (
        <span key={i} style={{ position: "relative", display: "inline-block", width: size, height: size, lineHeight: 0 }}>
          <svg viewBox="0 0 24 24" fill={kind === "empty" ? "transparent" : "currentColor"} stroke="currentColor" strokeWidth="1.6" width={size} height={size} style={{ position: "absolute", inset: 0 }}>
            <path d="M12 2l2.9 6.6 7.1.7-5.4 4.8 1.7 7-6.3-3.7-6.3 3.7 1.7-7L2 9.3l7.1-.7L12 2z" />
          </svg>
          {kind === "half" && (
            <svg viewBox="0 0 24 24" fill="currentColor" width={size} height={size} style={{ position: "absolute", inset: 0, clipPath: "inset(0 50% 0 0)" }}>
              <path d="M12 2l2.9 6.6 7.1.7-5.4 4.8 1.7 7-6.3-3.7-6.3 3.7 1.7-7L2 9.3l7.1-.7L12 2z" />
            </svg>
          )}
        </span>
      ))}
    </span>
  );
};

const MatchedShopRow = ({ w, rank, specialty, address, emailValid, hideSpecialty, hideQuote, action, inspectionId, partnerId }) => {
  const [referBusy, setReferBusy] = React.useState(false);
  const [referDone, setReferDone] = React.useState(false);
  const [referError, setReferError] = React.useState(null);

  // "Book appointment" (appraisers, via `action`) / "Request quote" (workshops) refer the
  // inspection to this partner, then lock the button. 409 (already referred) counts as
  // done; a missing partner id just locks the button. Workshops also require a verified VIN.
  const isQuote = !action;
  const gated = isQuote && !emailValid;
  const submitReferral = async () => {
    if (referBusy || referDone || gated) return;
    setReferError(null);
    if (!inspectionId || !partnerId) { setReferDone(true); return; }
    setReferBusy(true);
    try {
      await OK_API.createPartnerReferral(inspectionId, partnerId);
      setReferDone(true);
    } catch (e) {
      if (e.status === 409) setReferDone(true);
      else setReferError(e.message || t("MatchedShopRow.referError"));
    } finally {
      setReferBusy(false);
    }
  };

  const idleLabel = isQuote ? t("MatchedShopRow.quoteIdle") : (action.label || t("MatchedShopRow.bookIdle"));
  const busyLabel = isQuote ? t("MatchedShopRow.quoteBusy") : t("MatchedShopRow.bookBusy");
  const doneLabel = isQuote ? t("MatchedShopRow.quoteDone") : t("MatchedShopRow.bookDone");

  return (
  <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
    {/* Header — name + rank */}
    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
      <div style={{ minWidth: 0, flex: 1 }}>
        <div style={{ fontSize: 17, fontWeight: 600, letterSpacing: "-0.015em" }}>{w.name}</div>
        <div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6, fontSize: 13, color: "var(--mute)" }}>
          <Ic.pin width="13" height="13" style={{ flexShrink: 0 }} />
          <span>{address || w.city} · {w.dist} km</span>
        </div>
      </div>
      <span className="mono" style={{
        fontSize: 13, color: "var(--mute-2)", fontWeight: 500, flexShrink: 0,
        letterSpacing: "0.02em",
      }}>#{rank}</span>
    </div>

    {/* Rating row (rating/reviews are nullable on real partner data) */}
    {w.rating != null && (
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <Stars value={w.rating} size={14} />
        <span style={{ fontSize: 13.5, fontWeight: 500 }}>{w.rating}</span>
        {w.reviews != null && <span style={{ fontSize: 13, color: "var(--mute)" }}>({w.reviews} {t("MatchedShopRow.reviews")})</span>}
      </div>
    )}

    {/* Specialty */}
    {!hideSpecialty && specialty && (
      <div style={{
        fontSize: 13, color: "var(--ink-2)", display: "flex", gap: 8, alignItems: "flex-start",
        padding: "10px 12px", background: "var(--bg)", borderRadius: 8,
      }}>
        <Ic.wrench2 width="14" height="14" style={{ color: "var(--accent-ink)", flexShrink: 0, marginTop: 1 }} />
        <span style={{ lineHeight: 1.45 }}>{L(specialty)}</span>
      </div>
    )}

    {/* Actions */}
    <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 4 }}>
      <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
        {(action || !hideQuote) && (
          <button
            className="btn btn-accent"
            data-testid="shop-action"
            data-done={referDone ? "true" : "false"}
            disabled={referBusy || referDone || gated}
            onClick={submitReferral}
            style={{ justifyContent: "center", cursor: (referBusy || referDone || gated) ? "default" : "pointer", opacity: (referBusy || referDone || gated) ? 0.5 : 1 }}
          >
            {referDone
              ? <><Ic.check width="14" height="14" /> {doneLabel}</>
              : <><Ic.sparkle width="14" height="14" /> {referBusy ? busyLabel : idleLabel}</>}
          </button>
        )}
        <button
          className="btn btn-ghost"
          onClick={() => window.open(partnerMapHref(w, address), "_blank", "noopener,noreferrer")}
          style={{ justifyContent: "center", cursor: "pointer" }}
        >
          <Ic.pin width="14" height="14" /> {t("MatchedShopRow.viewOnMap")}
        </button>
      </div>
      {referError && <div style={{ fontSize: 12, color: "#BE123C" }}>{referError}</div>}
    </div>
  </div>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// KFZ GUTACHTER

const GutachterSection = ({ costMin, costMax, inspectionId, signedUp, postcode, onPostcodeChange }) => {
  // Prefer OttoKlar partner appraisers, fall back to independents — appraisers have no service
  // concept, so the rule is purely distance: show partner appraisers within MAX_RADIUS_KM, else
  // independent (Google-Places) Gutachter nearby (max MAX_INDEPENDENT). Both lists are auth-gated,
  // so we only fetch once the email gate is unlocked (signedUp). Re-runs when the postcode changes.
  const [partners, setPartners] = React.useState(null); // null = loading
  React.useEffect(() => {
    if (!inspectionId || !signedUp) return;
    let alive = true;
    setPartners(null);
    OK_API.getInspectionPartners(inspectionId, { type: "Appraiser", postcode })
      .then((rows) => { if (alive) setPartners(Array.isArray(rows) ? rows : []); })
      .catch(() => { if (alive) setPartners([]); });
    return () => { alive = false; };
  }, [inspectionId, signedUp, postcode]);

  const inRange = partnersInRange(partners || []);
  const showPartners = inRange.length > 0;

  // Independent appraiser fallback — fetched lazily, only once the partner list is back and none
  // are in range. null = loading/not-yet-fetched.
  const [independents, setIndependents] = React.useState(null);
  React.useEffect(() => {
    if (!inspectionId || !signedUp || partners === null || showPartners) { setIndependents(null); return; }
    let alive = true;
    setIndependents(null);
    OK_API.getNearbyAppraisers(inspectionId, { postcode, limit: MAX_INDEPENDENT })
      .then((rows) => { if (alive) setIndependents(Array.isArray(rows) ? rows : []); })
      .catch(() => { if (alive) setIndependents([]); });
    return () => { alive = false; };
  }, [inspectionId, signedUp, postcode, partners, showPartners]);

  const costNum = costMax != null ? costMax : costMin;
  const costRange = (costMin != null && costMax != null && Math.round(costMin) !== Math.round(costMax))
    ? `€${Math.round(costMin).toLocaleString("de-DE")}–${Math.round(costMax).toLocaleString("de-DE")}`
    : costNum != null
      ? `€${Math.round(costNum).toLocaleString("de-DE")}`
      : null;

  const independentBranch = partners !== null && !showPartners;

  let rows;
  if (partners === null) {
    rows = partnerStateRow(t("GutachterSection.findingAppraisers"));
  } else if (showPartners) {
    rows = inRange.map((p, i) => {
      const m = mapNearbyPartner(p);
      return (
        <div key={m.id || p.name || i} style={{ padding: "20px 0", borderTop: "1px dashed var(--rule)" }}>
          <MatchedShopRow w={m.w} rank={m.rank || i + 1} address={m.address} specialty={m.specialty} action={{ label: t("MatchedShopRow.bookIdle") }} inspectionId={inspectionId} partnerId={m.id} />
        </div>
      );
    });
  } else if (independents === null) {
    rows = partnerStateRow(t("GutachterSection.findingAppraisers"));
  } else if (independents.length === 0) {
    rows = partnerStateRow(t("GutachterSection.emptyAppraisers"));
  } else {
    rows = independents.slice(0, MAX_INDEPENDENT).map((s, i) => {
      const m = mapNearbyWorkshop(s);
      return (
        <div key={`${s.name}-${i}`} style={{ padding: "20px 0", borderTop: "1px dashed var(--rule)" }}>
          <MatchedShopRow w={m.w} rank={m.rank || i + 1} address={m.address} hideSpecialty hideQuote />
        </div>
      );
    });
  }

  const card = (
    <div className="card" style={{ padding: 28 }}>
      <h3 className="h3" data-testid="appraisers-heading" data-variant={independentBranch ? "independent" : "partners"} style={{ fontSize: 20, marginBottom: 6 }}>
        {independentBranch ? t("GutachterSection.headingIndependent") : t("GutachterSection.headingPartners")}
      </h3>
      <p style={{ margin: "0 0 18px", fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.55 }}>
        {t("GutachterSection.bodyOfficial")}
      </p>

      {independentBranch ? (
        <div style={{
          display: "flex", gap: 12, alignItems: "flex-start",
          padding: "14px 16px", borderRadius: 10, marginBottom: 18,
          background: "var(--accent-tint)",
        }}>
          <Ic.pin width="16" height="16" style={{ color: "var(--accent-ink)", flexShrink: 0, marginTop: 1 }} />
          <div style={{ fontSize: 13.5, color: "var(--accent-ink)", lineHeight: 1.55 }}>
            <b>{t("GutachterSection.comingSoonBold")}</b> {t("GutachterSection.comingSoonBody")}
          </div>
        </div>
      ) : (costRange && (
        <div style={{
          display: "flex", gap: 12, alignItems: "flex-start",
          padding: "14px 16px", borderRadius: 10, marginBottom: 18,
          background: "var(--accent-tint)",
        }}>
          <Ic.sparkle width="16" height="16" style={{ color: "var(--accent-ink)", flexShrink: 0, marginTop: 1 }} />
          <p style={{ margin: 0, fontSize: 13.5, color: "var(--accent-ink)", lineHeight: 1.55 }}>
            {t("GutachterSection.costEntitledPre")} <b>{costRange}</b>. {t("GutachterSection.costEntitledMid")} <b>{t("GutachterSection.costEntitledFree")}</b> {t("GutachterSection.costEntitledPost")}
          </p>
        </div>
      ))}

      <div style={{ borderTop: "1px solid var(--rule)" }} />

      {/* Zip line */}
      <div data-testid="appraiser-postcode-row" style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: "var(--mute)", padding: "10px 0 20px" }}>
        <Ic.pin width="13" height="13" style={{ flexShrink: 0 }} />
        {t("GutachterSection.showingNear")}
        <ZipPicker value={postcode} onChange={onPostcodeChange} />
      </div>

      {rows}
    </div>
  );

  return card;
};

Object.assign(window, { ResultsPage });
