// ===========================================================
// MyCollectionAdd_v2 — verified-collection add flow.
//
//   consent? -> entry -> (scan | search the Hundred | manual)
//            -> confirm the exact Discogs release
//            -> grade media + sleeve (Goldmine, no default)
//            -> server-side estimate -> onAdd(item)
//
// Emits a collection_items-shaped object. DB writes + the consent
// timestamp are handled by the parent (props), so this stays pure UI.
//
// Props:
//   albums         BBR_ALBUMS (the Hundred)
//   acceptedTerms  bool — has the user accepted the public-collection terms?
//   onAcceptTerms  async () => persist profiles.accepted_public_terms_at
//   onAdd          (item) => persist a collection_items row
//   onClose        () => dismiss
// ===========================================================
const { useState: useAddV2State, useMemo: useAddV2Memo, useEffect: useAddV2Effect } = React;

// Goldmine display code -> DB grade enum spelling.
function toGradeEnum(code) {
  if (code === "VG+") return "VG_PLUS";
  if (code === "G+") return "G_PLUS";
  return code;
}
function normTitle(s) { return (s || "").toString().toLowerCase().replace(/[^a-z0-9]/g, ""); }

// Light canon auto-link: if a confirmed release's artist+title closely matches
// an album in the Hundred, attach that slug so it counts toward the canon board.
function matchCanonSlug(albums, artist, title) {
  const t = normTitle(title), a = normTitle(artist);
  if (!t) return null;
  const hit = albums.find(al => {
    const at = normTitle(al.title);
    return at && (at === t || at.includes(t) || t.includes(at)) &&
      (!a || normTitle(al.artist).includes(a) || a.includes(normTitle(al.artist)));
  });
  return hit ? hit.slug : null;
}

function gbp(n) { return "£" + Math.round(n).toLocaleString("en-GB"); }

// Downscale a chosen/captured photo to a small JPEG before upload: longest edge
// ~1024px, quality ~0.8. Keeps the /api/identify-cover payload small and fast,
// and never sends the full-resolution camera image. Returns a data: URL.
function downscaleImage(file, maxEdge, quality) {
  return new Promise((resolve, reject) => {
    const url = URL.createObjectURL(file);
    const img = new Image();
    img.onload = () => {
      URL.revokeObjectURL(url);
      let w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
      const longest = Math.max(w, h);
      if (longest > maxEdge) { const s = maxEdge / longest; w = Math.round(w * s); h = Math.round(h * s); }
      const canvas = document.createElement("canvas");
      canvas.width = w; canvas.height = h;
      try {
        canvas.getContext("2d").drawImage(img, 0, 0, w, h);
        resolve(canvas.toDataURL("image/jpeg", quality));
      } catch (e) { reject(e); }
    };
    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("image load failed")); };
    img.src = url;
  });
}

// Note: public-collection consent is no longer captured here. Collections are
// PRIVATE by default; adding a record exposes nothing. Consent is captured at
// the moment a user makes their collection public, from the visibility toggle
// on their collection page (store.setVisibility). The acceptedTerms /
// onAcceptTerms props are retained for back-compat but are no longer used to
// gate the add flow.
function MyCollectionAdd_v2({ albums, acceptedTerms, onAcceptTerms, onAdd, onClose, onImport }) {
  // Back button (mobile/iOS) closes the add sheet instead of leaving the page.
  window.BBR_useModalBack(onClose);

  // entry | scan | photo | candidates | search | manual | grade
  const [step, setStep] = useAddV2State("entry");
  const [entryMode, setEntryMode] = useAddV2State("scan"); // scan -> 'scanned', else 'manual'

  const [candidates, setCandidates] = useAddV2State([]);
  const [candNote, setCandNote] = useAddV2State("");
  const [candBusy, setCandBusy] = useAddV2State(false);

  const [pendingSlug, setPendingSlug] = useAddV2State(null); // canon slug when picked from the Hundred
  const [chosen, setChosen] = useAddV2State(null);           // confirmed release

  const [query, setQuery] = useAddV2State("");
  const [catno, setCatno] = useAddV2State("");
  const [manual, setManual] = useAddV2State({ artist: "", title: "", year: "" });
  // Where the current candidate list came from — drives only the "← Back"
  // target on the candidates pane. verification_level is driven by entryMode.
  const [candOrigin, setCandOrigin] = useAddV2State("scan");

  // Photo-to-add: capture/upload a cover photo, identify it server-side, then
  // confirm against Discogs (never written without the user confirming).
  const [photoBusy, setPhotoBusy] = useAddV2State(false);
  const [photoPreview, setPhotoPreview] = useAddV2State(null);
  const [photoNote, setPhotoNote] = useAddV2State("");

  const [media, setMedia] = useAddV2State(null);   // Goldmine display code, no default
  const [sleeve, setSleeve] = useAddV2State(null);
  const [valuing, setValuing] = useAddV2State(false);
  const [estValue, setEstValue] = useAddV2State(null);
  const [estBasis, setEstBasis] = useAddV2State("");
  const [saving, setSaving] = useAddV2State(false);
  const [saveErr, setSaveErr] = useAddV2State("");   // shown in the grade step on a failed save
  // Optional purchase metadata (price / gift / date / where). Shape mirrors
  // PurchaseFields; normalised into the insert by window.purchasePayload.
  const [purchase, setPurchase] = useAddV2State({ purchase_price: null, is_gift: false, purchase_date: null, purchase_location: "" });

  // Can we offer "scan instead" as a no-match suggestion? Only when a camera +
  // secure context + decoder are actually present, so we never suggest scanning
  // on a device that can't (e.g. a desktop with no camera) where it would just
  // dead-end. Mirrors BarcodeScanner's own preconditions; enumerateDevices is
  // permission-free (a videoinput entry exists even before the user grants it).
  const [canScan, setCanScan] = useAddV2State(false);
  useAddV2Effect(() => {
    let cancelled = false;
    const Z = window.ZXing;
    const secure = window.isSecureContext ||
      ["localhost", "127.0.0.1"].includes(window.location.hostname);
    const md = navigator.mediaDevices;
    if (!Z || !Z.BrowserMultiFormatReader || !secure ||
        !md || !md.getUserMedia || !md.enumerateDevices) return;
    md.enumerateDevices()
      .then(devs => { if (!cancelled) setCanScan(devs.some(d => d.kind === "videoinput")); })
      .catch(() => {});
    return () => { cancelled = true; };
  }, []);

  // The add flow is a full-screen overlay on mobile, so lock the page behind it:
  // it must not be the thing that scrolls while the overlay is open. Plain
  // `overflow:hidden` on body is ignored by iOS Safari (the page still pans, which
  // drags the fixed layer in every direction), so we pin the body with
  // position:fixed at a negative top equal to the current scroll, then restore the
  // exact styles and scroll position on close/unmount (the parent mounts this
  // component only while open, so unmount == close).
  useAddV2Effect(() => {
    const scrollY = window.scrollY || window.pageYOffset || 0;
    const b = document.body;
    const prev = {
      position: b.style.position, top: b.style.top, left: b.style.left,
      right: b.style.right, width: b.style.width, overflow: b.style.overflow,
    };
    b.style.position = "fixed";
    b.style.top = "-" + scrollY + "px";
    b.style.left = "0";
    b.style.right = "0";
    b.style.width = "100%";
    b.style.overflow = "hidden";
    return () => {
      b.style.position = prev.position; b.style.top = prev.top;
      b.style.left = prev.left; b.style.right = prev.right;
      b.style.width = prev.width; b.style.overflow = prev.overflow;
      window.scrollTo(0, scrollY);
    };
  }, []);

  // ---- candidate lookup (barcode OR text q) --------------------------------
  async function lookup(endpoint, params, mode, origin) {
    setEntryMode(mode);
    setCandOrigin(origin);
    setCandBusy(true); setCandidates([]); setCandNote(""); setStep("candidates");
    try {
      const j = await fetch(endpoint + "?" + params).then(r => r.json());
      setCandidates(j.candidates || []);
      setCandNote(j.note || "");
    } catch (e) {
      setCandNote("Couldn't reach the lookup just now. Try again, or add manually.");
    } finally {
      setCandBusy(false);
    }
  }
  // Clear any canon slug carried over from a prior search/photo attempt in the
  // same modal session — a barcode/catalogue record's canon link must come from
  // matchCanonSlug on the actually-chosen release, never a stale pendingSlug.
  function lookupBarcode(code) { setPendingSlug(null); lookup("/api/barcode", "barcode=" + encodeURIComponent(code), "scan", "scan"); }
  function lookupQuery(artist, title) { lookup("/api/barcode", "q=" + encodeURIComponent((artist + " " + title).trim()), "manual", "search"); }
  // Catalogue-number lookup: hand-typed, but it goes through the SAME Discogs
  // pressing-match/confirm flow as a scan, so a chosen candidate carries real
  // provenance — recorded as verification_level "catalogue" (mode "catno"), not
  // "manual". Returns to its own pane on Back. (Bailing out to the manual pane
  // and adding without a Discogs match still records "manual" — see addUnmatched.)
  function lookupCatno(code) {
    const c = (code || "").trim();
    if (!c) return;
    setPendingSlug(null); // see lookupBarcode — don't carry a stale canon slug
    lookup("/api/catno", "catno=" + encodeURIComponent(c), "catno", "catno");
  }
  // Photo-identified album -> Discogs candidates, exactly like the search path so
  // the user confirms the exact pressing. Origin "photo" so "← Back" returns to
  // the photo pane. verification_level stays "manual" (mode "manual") — a photo
  // isn't a barcode/catalogue scan, and we don't add new fields here (Brief A
  // owns the schema).
  function lookupPhoto(artist, title) {
    lookup("/api/barcode", "q=" + encodeURIComponent((artist + " " + title).trim()), "manual", "photo");
  }

  function resetPhoto() { setPhotoBusy(false); setPhotoPreview(null); setPhotoNote(""); }

  // Capture/choose -> downscale -> identify -> (canon link +) Discogs confirm, or
  // fall back to manual. NEVER writes a row from the vision result; the user
  // always confirms in the candidates + grade steps.
  async function onPhotoFile(file) {
    if (!file || photoBusy) return;
    setPhotoNote("");
    let dataUrl = null;
    try { dataUrl = await downscaleImage(file, 1024, 0.8); } catch (e) { dataUrl = null; }
    if (!dataUrl) {
      setManual({ artist: "", title: "", year: "" });
      setPhotoNote("Couldn't read that image. Search or enter the details manually.");
      setStep("manual");
      return;
    }
    setPhotoPreview(dataUrl);
    setPhotoBusy(true);
    try {
      const j = await fetch("/api/identify", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ image: dataUrl }),
      }).then(r => r.json());

      if (j && j.confidence && j.confidence !== "none" && j.artist && j.title) {
        // Match the canon first so a confident hit links the row to a real
        // dossier; pre-fill the manual form too so the escape hatch carries the
        // guess. Then confirm the exact pressing on Discogs.
        setPendingSlug(matchCanonSlug(albums, j.artist, j.title));
        setManual({ artist: j.artist, title: j.title, year: j.year ? String(j.year) : "" });
        if (j.confidence === "low") {
          setPhotoNote("We think this is " + j.artist + " — " + j.title + ". Check it's right before saving.");
        }
        lookupPhoto(j.artist, j.title);
      } else {
        setManual({ artist: "", title: "", year: "" });
        setPhotoNote("Couldn't read that cover. Search or enter the details manually.");
        setStep("manual");
      }
    } catch (e) {
      setManual({ artist: "", title: "", year: "" });
      setPhotoNote("Couldn't read that cover. Search or enter the details manually.");
      setStep("manual");
    } finally {
      setPhotoBusy(false);
    }
  }

  // ---- choose / confirm a release ------------------------------------------
  function chooseCandidate(c) {
    setChosen({
      discogs_release_id: c.id,
      artist: c.artist || "",
      title: c.title || "",
      year: c.year || null,
      genre: c.genre || null,
      barcode: c.barcode || null,
      cover: c.cover || c.thumb || null,
      slug: pendingSlug || matchCanonSlug(albums, c.artist, c.title),
    });
    setMedia(null); setSleeve(null); setEstValue(null); setEstBasis("");
    setStep("grade");
  }
  // Manual add with no Discogs match: unvalued, genre unknown, manual trust.
  function addUnmatched() {
    const m = manual;
    setChosen({
      discogs_release_id: null,
      artist: m.artist.trim(), title: m.title.trim(),
      year: parseInt(m.year, 10) || null,
      genre: null, barcode: null, cover: null,
      slug: matchCanonSlug(albums, m.artist, m.title),
    });
    setEntryMode("manual");
    setMedia(null); setSleeve(null); setEstValue(null); setEstBasis("");
    setStep("grade");
  }

  // ---- value when a media grade is chosen (server-side) --------------------
  useAddV2Effect(() => {
    if (step !== "grade" || !media || !chosen || !chosen.discogs_release_id) { return; }
    let cancelled = false;
    setValuing(true);
    fetch("/api/discogs-price?release_id=" + chosen.discogs_release_id + "&grade=" + toGradeEnum(media))
      .then(r => r.json())
      .then(j => {
        if (cancelled) return;
        setEstValue(typeof j.est_value === "number" ? j.est_value : null);
        setEstBasis(j.basis || "");
        if (j.genre && !chosen.genre) setChosen(prev => ({ ...prev, genre: j.genre }));
      })
      .catch(() => { if (!cancelled) { setEstValue(null); setEstBasis(""); } })
      .finally(() => { if (!cancelled) setValuing(false); });
    return () => { cancelled = true; };
    // Depend on the release id, not the whole `chosen` object: patching the
    // genre below replaces `chosen`, which would otherwise re-trigger this and
    // fire a second, redundant price fetch for the same pressing.
  }, [media, step, chosen && chosen.discogs_release_id]);

  async function confirmAdd() {
    if (!chosen || !media || !sleeve || saving) return;
    if (window.purchaseInvalid && window.purchaseInvalid(purchase)) return;
    setSaving(true);
    const item = {
      slug: chosen.slug || null,
      discogs_release_id: chosen.discogs_release_id || null,
      barcode: chosen.barcode || null,
      artist: chosen.artist,
      title: chosen.title,
      year: chosen.year || null,
      genre: chosen.genre || null,
      media_condition: toGradeEnum(media),
      sleeve_condition: toGradeEnum(sleeve),
      est_value: typeof estValue === "number" ? estValue : null,
      value_currency: "GBP",
      verification_level: entryMode === "scan" ? "scanned"
        : entryMode === "catno" ? "catalogue"
        : "manual",
      // optional purchase metadata, normalised (gift -> price 0; empty -> null)
      ...(window.purchasePayload ? window.purchasePayload(purchase) : {}),
    };
    // A failed save used to fall through `finally` with no error surfaced, so
    // the sheet closed on a success animation while the record was discarded.
    try {
      setSaveErr("");
      await onAdd(item);
    } catch (e) {
      setSaveErr("We couldn't save that record. Nothing was lost — try again.");
    } finally { setSaving(false); }
  }

  const purchaseBad = window.purchaseInvalid ? window.purchaseInvalid(purchase) : false;

  const canonResults = useAddV2Memo(() => {
    const q = query.trim().toLowerCase();
    const list = albums.slice().sort((a, b) => a.rank - b.rank);
    if (!q) return list.slice(0, 40);
    return list.filter(a => (a.title + " " + a.artist).toLowerCase().includes(q)).slice(0, 40);
  }, [query, albums]);

  // ====== render ======
  return (
    <div className="av2-backdrop" onClick={onClose}>
      <div className="av2-modal" onClick={(e) => e.stopPropagation()}>
        <div className="av2-head">
          <div className="av2-head-left">
            <button className="av2-back-arrow" aria-label="Back to collection" onClick={onClose}>
              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 4l-5 6 5 6" /></svg>
            </button>
            <span className="av2-head-title">Add a record</span>
          </div>
          <button className="av2-x" onClick={onClose}>×</button>
        </div>

        <div className="av2-body">
          {step === "entry" && (
            <div className="av2-pane av2-entry">
              <h3 className="av2-title">How do you want to add it?</h3>
              <button className="av2-entry-primary" onClick={() => setStep("scan")}>
                <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M6 12h12"/></svg>
                <span>Scan the barcode</span>
                <small>Best for newer records</small>
              </button>
              <button className="av2-entry-primary" onClick={() => { resetPhoto(); setStep("photo"); }}>
                <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
                <span>Snap the cover</span>
                <small>Best when there's no barcode</small>
              </button>
              <button className="av2-entry-primary" onClick={() => { setCatno(""); setStep("catno"); }}>
                <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><rect x="3" y="5" width="18" height="14" rx="1.5"/><path d="M7 9h6M7 13h10"/></svg>
                <span>Enter catalogue number</span>
                <small>Best for older / original pressings</small>
              </button>
              {onImport && (
                <button className="av2-entry-primary" onClick={onImport}>
                  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>
                  <span>Import a collection</span>
                  <small>Bring in a whole shelf from a CSV or Discogs</small>
                </button>
              )}
              <button className="av2-entry-secondary" onClick={() => setStep("search")}>
                Search the Hundred
              </button>
              <button className="av2-entry-link" onClick={() => { setManual({ artist: "", title: "", year: "" }); setStep("manual"); }}>
                Can't find it? Add manually
              </button>
              <p className="av2-public-note">
                Your collection is <b>private</b> by default. Make it public any time from your collection page to appear on the leaderboards.
              </p>
            </div>
          )}

          {step === "scan" && (
            <BarcodeScanner
              onDetect={lookupBarcode}
              onManual={lookupBarcode}
              onClose={() => setStep("entry")}
            />
          )}

          {step === "photo" && (
            <div className="av2-pane">
              <button className="av2-back-link" onClick={() => setStep("entry")}>← Back</button>
              <h3 className="av2-title">Snap the cover</h3>
              <p className="av2-hint">Take a photo of the album cover, or choose one. We'll read it and find the record — you'll always confirm before it's saved.</p>
              <label style={{ display: "block", border: "1px dashed var(--rule)", borderRadius: 12, padding: 20, textAlign: "center", cursor: photoBusy ? "default" : "pointer", margin: "4px 0 12px" }}>
                {photoPreview
                  ? <img src={photoPreview} alt="cover preview" style={{ maxWidth: "100%", maxHeight: 220, borderRadius: 8, display: "block", margin: "0 auto" }} />
                  : (
                    <span style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, color: "var(--ink-mute)" }}>
                      <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
                      <span>Take or choose a photo</span>
                    </span>
                  )}
                <input
                  type="file"
                  accept="image/*"
                  capture="environment"
                  disabled={photoBusy}
                  style={{ display: "none" }}
                  onChange={(e) => onPhotoFile(e.target.files && e.target.files[0])}
                />
              </label>
              {photoBusy && <p className="av2-hint">Reading the cover…</p>}
              {photoNote && <p className="av2-hint" style={{ color: "var(--ink-mute)" }}>{photoNote}</p>}
              <button className="av2-entry-link" onClick={() => { setManual({ artist: "", title: "", year: "" }); setStep("manual"); }}>
                Enter the details manually instead
              </button>
            </div>
          )}

          {step === "catno" && (
            <div className="av2-pane">
              <button className="av2-back-link" onClick={() => setStep("entry")}>← Back</button>
              <h3 className="av2-title">Enter the catalogue number</h3>
              <p className="av2-hint">Look on the spine, centre label, or back cover — e.g. <span className="av2-catno-eg">SHVL 804</span>. Best for older or original pressings without a barcode.</p>
              <form className="av2-catno-form" onSubmit={(e) => { e.preventDefault(); lookupCatno(catno); }}>
                <input
                  className="av2-catno-input"
                  type="text"
                  inputMode="text"
                  autoCapitalize="characters"
                  autoCorrect="off"
                  autoComplete="off"
                  spellCheck="false"
                  autoFocus
                  placeholder="Catalogue number"
                  value={catno}
                  onChange={(e) => setCatno(e.target.value)}
                />
                <button className="av2-catno-go" type="submit" disabled={!catno.trim()}>Look up</button>
              </form>
              <button className="av2-entry-link" onClick={() => { setManual({ artist: "", title: "", year: "" }); setStep("manual"); }}>
                Can't find it? Add manually
              </button>
            </div>
          )}

          {step === "candidates" && (
            <div className="av2-pane">
              <button className="av2-back-link" onClick={() => setStep(candOrigin)}>← Back</button>
              <h3 className="av2-title">Confirm your pressing</h3>
              {(candBusy || candidates.length > 0) && (
                <p className="av2-hint">{candBusy ? "Looking up…" : candNote}</p>
              )}
              {photoNote && candOrigin === "photo" && !candBusy && (
                <p className="av2-hint" style={{ color: "var(--ink-mute)" }}>{photoNote}</p>
              )}
              <div className="av2-cand-list">
                {candidates.map(c => (
                  <button className="av2-cand" key={c.id} onClick={() => chooseCandidate(c)}>
                    {c.thumb ? <img className="av2-cand-thumb" src={c.thumb} alt="" loading="lazy" />
                             : <span className="av2-cand-thumb av2-cand-thumb-blank" />}
                    <span className="av2-cand-main">
                      <span className="av2-cand-t"><b>{c.artist}</b> — {c.title}</span>
                      <span className="av2-cand-meta">{[c.year, c.country, c.label, c.catno].filter(Boolean).join(" · ")}</span>
                      <span className="av2-cand-fmt">{c.format}{c.genre ? " · " + c.genre : ""}</span>
                    </span>
                  </button>
                ))}
              </div>
              {candOrigin === "photo" && !candBusy && candidates.length > 0 && (
                <button className="av2-entry-link" onClick={() => setStep("manual")}>
                  Not right? Search manually
                </button>
              )}
              {!candBusy && candidates.length === 0 && (() => {
                // Suggest the OTHER method as the primary action: a barcode miss
                // -> catalogue number; a catalogue miss -> scan (only if a camera
                // is usable). Don't carry the failed value over — drop into a clean
                // entry. Manual is the final fallback (secondary), unless there's no
                // suggestion to offer, in which case it leads (primary).
                const suggestCatno = candOrigin === "scan";
                const suggestScan  = candOrigin === "catno" && canScan;
                const hasSuggestion = suggestCatno || suggestScan;
                // From a photo identify the manual form is already prefilled with
                // the confident guess (and pendingSlug holds the canon slug), so a
                // canon album that returns zero Discogs pressings keeps its dossier
                // link through addUnmatched's matchCanonSlug rather than starting
                // blank. Other origins drop the failed value into a clean entry.
                const goManual = () => {
                  if (candOrigin !== "photo") setManual({ artist: "", title: "", year: "" });
                  setStep("manual");
                };
                return (
                  <div className="av2-entry">
                    <p className="av2-hint">
                      {/* candNote carries the real reason (offline, rate-limited,
                          lookup not configured). Without it every failure read as
                          "your record doesn't exist". */}
                      {candNote || photoNote || (candOrigin === "scan" ? "No match for that barcode."
                        : candOrigin === "catno" ? "No match for that catalogue number."
                        : "No matches found.")}
                    </p>
                    {suggestCatno && (
                      <button className="av2-entry-primary" onClick={() => { setCatno(""); setStep("catno"); }}>
                        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><rect x="3" y="5" width="18" height="14" rx="1.5"/><path d="M7 9h6M7 13h10"/></svg>
                        <span>Add by catalogue number</span>
                        <small>Best for older / original pressings</small>
                      </button>
                    )}
                    {suggestScan && (
                      <button className="av2-entry-primary" onClick={() => setStep("scan")}>
                        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M6 12h12"/></svg>
                        <span>Scan the barcode</span>
                        <small>Best for newer records</small>
                      </button>
                    )}
                    {hasSuggestion ? (
                      <button className="av2-entry-link" onClick={goManual}>
                        Add manually
                      </button>
                    ) : (
                      <button className="av2-entry-primary" onClick={goManual}>
                        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
                        <span>Add manually</span>
                        <small>Type the artist, title and year yourself</small>
                      </button>
                    )}
                  </div>
                );
              })()}
            </div>
          )}

          {step === "search" && (
            <div className="av2-pane">
              <button className="av2-back-link" onClick={() => setStep("entry")}>← Back</button>
              <h3 className="av2-title">Search the Hundred</h3>
              <div className="av2-search">
                <input autoFocus placeholder="Title or artist…" value={query} onChange={(e) => setQuery(e.target.value)} />
              </div>
              <div className="av2-results">
                {canonResults.map(a => (
                  <button className="av2-res" key={a.slug} onClick={() => { setPendingSlug(a.slug); lookupQuery(a.artist, a.title); }}>
                    <span className="av2-res-t">{a.title}</span>
                    <span className="av2-res-a">{a.artist} · {a.year}</span>
                    <span className="av2-res-rank">№ {String(a.rank).padStart(3, "0")}</span>
                  </button>
                ))}
              </div>
              <button className="av2-entry-link" onClick={() => { setManual({ artist: "", title: "", year: "" }); setStep("manual"); }}>
                Not in the Hundred? Add manually
              </button>
            </div>
          )}

          {step === "manual" && (
            <div className="av2-pane">
              <button className="av2-back-link" onClick={() => setStep("entry")}>← Back</button>
              <h3 className="av2-title">Add manually</h3>
              <div className="av2-manual-grid">
                <label>Artist<input value={manual.artist} onChange={(e) => setManual({ ...manual, artist: e.target.value })} placeholder="Artist" autoFocus /></label>
                <label>Title<input value={manual.title} onChange={(e) => setManual({ ...manual, title: e.target.value })} placeholder="Album title" /></label>
                <label>Year<input value={manual.year} onChange={(e) => setManual({ ...manual, year: e.target.value })} placeholder="1975" inputMode="numeric" /></label>
              </div>
              <div className="av2-foot">
                <button className="av2-back" onClick={addUnmatched} disabled={!manual.artist.trim() || !manual.title.trim()}>
                  Add without Discogs match
                </button>
                {/* Clear any canon slug carried over from a prior photo identify or
                    Hundred pick before searching for the hand-typed album — this
                    record's canon link must come from matchCanonSlug on the chosen
                    release, never a stale pendingSlug. Completes bf63092 (which
                    covered barcode/catalogue) for the manual-search path it missed. */}
                <button className="av2-next" disabled={!manual.artist.trim() || !manual.title.trim()}
                  onClick={() => { setPendingSlug(null); lookupQuery(manual.artist, manual.title); }}>
                  Find on Discogs
                </button>
              </div>
            </div>
          )}

          {step === "grade" && chosen && (
            <div className="av2-pane">
              <button className="av2-back-link" onClick={() => setStep(chosen.discogs_release_id ? "candidates" : "manual")}>← Back</button>
              <div className="av2-chosen">
                {chosen.cover && <img className="av2-chosen-cover" src={chosen.cover} alt="" />}
                <div>
                  <div className="av2-chosen-t"><b>{chosen.artist}</b> — {chosen.title}</div>
                  <div className="av2-chosen-meta">{[chosen.year, chosen.genre].filter(Boolean).join(" · ")}{chosen.discogs_release_id ? "" : " · no Discogs match"}</div>
                </div>
              </div>
              <h3 className="av2-title">Grade your copy</h3>
              <p className="av2-hint">Goldmine standard. Grade the record and the sleeve separately — no default, pick honestly.</p>

              {/* The bare grade codes assumed the collector already speaks
                  Goldmine — a title tooltip was the only explanation, which is
                  invisible on touch. Each row now narrates the selected grade
                  in plain words (the same blurbs the /list flow shows), and
                  before anything is picked it answers the question people
                  actually have: what separates VG+ from VG. */}
              {(() => {
                const gradeDesc = (code) => {
                  const c = code && window.BBR_CONDITIONS.find(x => x.code === code);
                  return c
                    ? <p className="av2-grade-desc"><b>{c.name}.</b> {c.blurb}</p>
                    : <p className="av2-grade-desc av2-grade-desc-hint">Not sure? <b>VG+</b> is light wear that doesn&rsquo;t affect play; <b>VG</b> means you can hear it in quiet passages. Most well-kept records are VG+.</p>;
                };
                return (
                  <>
                    <div className="av2-grade-group">
                      <div className="av2-grade-label">Media (the record)</div>
                      <div className="av2-grade-row">
                        {window.BBR_CONDITIONS.map(c => (
                          <button key={c.code} title={c.name} className={"av2-grade " + (media === c.code ? "sel" : "")} onClick={() => setMedia(c.code)}>{c.code}</button>
                        ))}
                      </div>
                      {gradeDesc(media)}
                    </div>
                    <div className="av2-grade-group">
                      <div className="av2-grade-label">Sleeve</div>
                      <div className="av2-grade-row">
                        {window.BBR_CONDITIONS.map(c => (
                          <button key={c.code} title={c.name} className={"av2-grade " + (sleeve === c.code ? "sel" : "")} onClick={() => setSleeve(c.code)}>{c.code}</button>
                        ))}
                      </div>
                      {gradeDesc(sleeve)}
                    </div>
                  </>
                );
              })()}

              <div className="av2-est">
                {!chosen.discogs_release_id ? <span className="av2-est-none">No Discogs match — added unvalued.</span>
                  : valuing ? <span className="av2-est-busy">Valuing…</span>
                  : estValue != null ? <span className="av2-est-val">{estBasis || ("est. " + gbp(estValue))}</span>
                  : media ? <span className="av2-est-none">No marketplace price for this pressing.</span>
                  : <span className="av2-est-none">Pick a media grade to see the estimate.</span>}
              </div>

              <div className="av2-grade-group">
                <div className="av2-grade-label">Purchase details (optional)</div>
                {window.PurchaseFields && <PurchaseFields value={purchase} onChange={setPurchase} />}
                {/* Always render the price error so its row is reserved on mobile
                    (CSS hides it when valid) — toggling validity must not reflow
                    the step. See av2-pf-err-reserve in collection.css. */}
                <p className={"av2-pf-err" + (purchaseBad ? "" : " av2-pf-err-reserve")}>Enter a price of 0 or more, or leave it blank.</p>
              </div>

              {saveErr && <p className="av2-save-err" role="alert" aria-live="assertive">{saveErr}</p>}

              <div className="av2-foot">
                <button className="av2-back" onClick={() => setStep(chosen.discogs_release_id ? "candidates" : "manual")}>← Back</button>
                <button className="av2-next" disabled={!media || !sleeve || saving || purchaseBad} onClick={confirmAdd}>
                  {saving ? "Adding…" : "Add to collection" + (estValue != null ? " · " + gbp(estValue) : "")}
                </button>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { MyCollectionAdd_v2 });
