// ---------------------------------------------------------------------------
// customer-grid / CatalogView.tsx
// Wave-18 owner item 4 (contract C6-CATALOG) — the CATALOG view.
//
// A view holds several catalogs; a catalog is an ordered list of pages; a
// section page names product CODES and joins them to the live pool at paint
// time. Three columns: the rail (catalogs, then that catalog's pages), the
// stage (the pages themselves, at true paper size, scaled to fit), and the
// inspector (everything about the selected page, plus the catalog's brand,
// paper and column bindings).
//
// FIDELITY. The default templates reproduce `Royal Collection 2027.pdf`, which
// was rendered and read before this file was written:
//   · section header — letterspaced caps, navy, light weight, top-left
//   · product renders on WHITE (the C2-ASSET web derivative is alpha-preserving
//     PNG precisely so this works)
//   · description in caps, centred, two lines
//   · a full-width navy PILL carrying the pack line
//   · SKU in terracotta bold on the left, colour name on the right
//   · a full-bleed lifestyle strip under the grid
//   · crown mark bottom-left, folio bottom-right
//   · gallery pages full-bleed with a letterspaced caps title
//
// TYPE. Catalog pages use a display SERIF for titles (`--cat-serif`, a system
// Didone/old-style stack — Didot, Bodoni, Playfair, Cormorant, Georgia). That is
// deliberate and it is the ONLY place it appears: app chrome stays Inter, per
// the owner constant. Listing text is Inter with wide tracking, which is what
// the reference's geometric sans reads as at listing size. NO EMOJIS anywhere —
// every mark here is a drawn path.
// ---------------------------------------------------------------------------

import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
  CATALOG_COLS,
  CATALOG_NAME_MAX,
  CATALOG_ORIENTATIONS,
  CATALOG_PAGE_KINDS,
  CATALOG_PAPERS,
  MAX_CATALOGS,
  MAX_CATALOG_PAGES,
  MAX_CATALOG_CODES,
} from "./types";
import type {
  CatalogOrientation,
  CatalogPage,
  CatalogPageKind,
  CatalogPaper,
  CatalogQuality,
  CatalogSpec,
  Field,
  Row,
} from "./types";
import {
  CATALOG_CODE_FIELD,
  DEFAULT_BRAND,
  EDITORIAL_PREFIX,
  PAGE_KIND_LABELS,
  PAPER_LABELS,
  addCodes,
  assetUrl,
  autoBind,
  codesUsed,
  editorialSlug,
  fetchAssetCodes,
  fetchEditorialSlugs,
  indexByCode,
  newCatalog,
  newPage,
  pageBox,
  resolveItems,
  sectionLayout,
  slugFromFilename,
  uploadEditorial,
} from "./catalogData";
import type { CatalogItem } from "./catalogData";
// Wave-19 R7 — the record-image namespace, so the gap report can say "I do not know" about an
// upload rather than marking every one of them missing.
import { RECORD_IMAGE_PREFIX } from "./types";
import { applyPrintCss, awaitImages, printOnce } from "./catalogPrint";
import { formatDisplay } from "./display";

// ------------------------------------------------------------------- marks

const STROKE = {
  stroke: "currentColor",
  strokeWidth: 1.5,
  strokeLinecap: "round" as const,
  strokeLinejoin: "round" as const,
  fill: "none",
};

function IconPlus() {
  return (
    <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
      <path d="M8 3.4v9.2M3.4 8h9.2" {...STROKE} />
    </svg>
  );
}
function IconTrash() {
  return (
    <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
      <path d="M3.4 4.6h9.2M6.4 4.6V3.2h3.2v1.4M4.8 4.6l.6 8h5.2l.6-8" {...STROKE} />
    </svg>
  );
}
function IconUp() {
  return (
    <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
      <path d="M4.4 9.6L8 6l3.6 3.6" {...STROKE} />
    </svg>
  );
}
function IconDown() {
  return (
    <svg width="13" height="13" viewBox="0 0 16 16" aria-hidden>
      <path d="M4.4 6.4L8 10l3.6-3.6" {...STROKE} />
    </svg>
  );
}
function IconPrint() {
  return (
    <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden>
      <path d="M4.6 6.4V2.8h6.8v3.6" {...STROKE} />
      <path d="M2.8 6.4h10.4v4.4H11v2.4H5v-2.4H2.8z" {...STROKE} />
      <path d="M5 10.8h6" {...STROKE} />
    </svg>
  );
}

/**
 * The Royal crown-diamond mark, drawn as paths.
 *
 * A row of three diamond outlines over a solid bar — the silhouette the 2027
 * catalogue carries on its cover, its footers and its back page. Drawn rather
 * than uploaded because a catalog has to print the mark at whatever size the
 * page gives it, and because an image dependency for the ONE element on every
 * page would make an unreachable asset route into a blank catalogue.
 */
function CrownMark({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 44 26" aria-hidden>
      <g fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round">
        <path d="M11 4.5l6.5 6.5L11 17.5 4.5 11z" />
        <path d="M22 2.5l7.5 7.5L22 17.5 14.5 10z" />
        <path d="M33 4.5L39.5 11 33 17.5 26.5 11z" />
      </g>
      <rect x="4" y="20" width="36" height="2.6" fill="currentColor" />
    </svg>
  );
}

// ------------------------------------------------------------ page templates

/** The image slot every template shares: the asset, or a STATED gap.
 *
 *  A missing master is drawn as a labelled frame naming the code, never as
 *  silence — the user has to be able to see, in the designer, that this page
 *  will print an empty box, and which SKU caused it. */
function Shot({
  code,
  quality,
  missing,
  cover,
}: {
  code?: string;
  quality: CatalogQuality;
  missing?: boolean;
  cover?: boolean;
}) {
  if (!code)
    return (
      <div className="cg-cat-shot cg-cat-shot-empty">
        <span>No image</span>
      </div>
    );
  if (missing)
    return (
      <div className="cg-cat-shot cg-cat-shot-empty">
        <span>
          {editorialSlug(code) !== null
            ? `No editorial image named ${editorialSlug(code)}`
            : `No master for ${code}`}
        </span>
      </div>
    );
  return (
    <div className={cover ? "cg-cat-shot cg-cat-shot-cover" : "cg-cat-shot"}>
      <img src={assetUrl(code, quality)} alt="" loading="eager" />
    </div>
  );
}

function ProductCard({
  item,
  quality,
  layout,
  hasAsset,
}: {
  item: CatalogItem;
  quality: CatalogQuality;
  layout: ReturnType<typeof sectionLayout>;
  hasAsset: (code: string) => boolean | null;
}) {
  // ⭐ Wave-19 R7 — the picture is the ITEM'S image reference, which equals the code on every
  // row nobody has overridden. Both the tile and the gap report read the SAME string, or the
  // designer could mark a product "no master" while printing a picture it does have.
  const known = hasAsset(item.image);
  return (
    <div className="cg-cat-card">
      <Shot code={item.image} quality={quality} missing={known === false} />
      {/* CAPS in CSS (`text-transform`), never by upper-casing the string: the
          value stays the value, so a copy of it is not shouting. */}
      <div className="cg-cat-desc">{item.name ?? (item.known ? "" : "Not in this table")}</div>
      {layout.showPack && item.pack ? <div className="cg-cat-pill">{item.pack}</div> : null}
      <div className="cg-cat-meta">
        <span className="cg-cat-sku">{item.code}</span>
        {layout.showColor && item.color ? (
          <span className="cg-cat-color">{item.color}</span>
        ) : null}
      </div>
      {layout.showPrice && item.price ? <div className="cg-cat-price">{item.price}</div> : null}
    </div>
  );
}

/** Crown mark left, folio right — exactly the reference's footer, which carries no
 *  company line (the wordmark's place is the cover). `light` inverts it for the
 *  pages whose field is dark.
 *
 *  `mark={false}` drops the crown: the intro page's terracotta lattice bleeds through the
 *  bottom-left corner, and a second terracotta mark sitting inside it reads as a smudge on the
 *  lattice rather than as the brand. The reference's own letter spread carries no crown there
 *  either — judged from the rendered page, not assumed. */
function PageFooter({
  folio,
  light,
  mark = true,
}: {
  folio: number;
  light?: boolean;
  mark?: boolean;
}) {
  return (
    <div className={light ? "cg-cat-foot cg-cat-foot-light" : "cg-cat-foot"}>
      {mark ? <CrownMark className="cg-cat-foot-mark" /> : null}
      <span className="cg-cat-folio">{folio}</span>
    </div>
  );
}

/** ONE page, at true paper size. The same component paints the stage and the
 *  print tree — there is no second renderer that could drift from what the
 *  screenshot showed. */
const CatalogPageView = memo(function CatalogPageView({
  cat,
  page,
  index,
  items,
  quality,
  hasAsset,
}: {
  cat: CatalogSpec;
  page: CatalogPage;
  index: number;
  items: CatalogItem[];
  quality: CatalogQuality;
  hasAsset: (code: string) => boolean | null;
}) {
  const box = pageBox(cat.paper, cat.orientation);
  const px = box.unit === "in" ? 96 : 96 / 25.4;
  const brand = cat.brand ?? {};
  const primary = brand.primary ?? DEFAULT_BRAND.primary;
  const accent = brand.accent ?? DEFAULT_BRAND.accent;
  const company = brand.company ?? DEFAULT_BRAND.company;
  const style = {
    "--cat-w": `${box.w * px}px`,
    "--cat-h": `${box.h * px}px`,
    "--cat-primary": primary,
    "--cat-accent": accent,
  } as React.CSSProperties;
  const folio = index + 1;
  const logoKnown = brand.logo ? hasAsset(brand.logo) : null;

  const Wordmark = (
    <div className="cg-cat-mark">
      {brand.logo && logoKnown !== false ? (
        <img src={assetUrl(brand.logo, quality)} alt="" />
      ) : (
        <CrownMark className="cg-cat-mark-crown" />
      )}
      <span className="cg-cat-mark-co">{company}</span>
    </div>
  );

  if (page.kind === "cover") {
    const known = page.imageCode ? hasAsset(page.imageCode) : null;
    return (
      <div className="cg-cat-page cg-cat-p-cover" style={style} data-kind="cover">
        <Shot code={page.imageCode} quality={quality} missing={known === false} cover />
        <div className="cg-cat-cover-scrim" />
        {Wordmark}
        <div className="cg-cat-cover-ttl">
          <span className="cg-cat-cover-a">{page.title}</span>
          {page.body ? <span className="cg-cat-cover-b">{page.body}</span> : null}
        </div>
      </div>
    );
  }

  if (page.kind === "intro") {
    return (
      <div className="cg-cat-page cg-cat-p-intro" style={style} data-kind="intro">
        <div className="cg-cat-intro-field">
          {/* The terracotta lattice bleeding off the left edge — the reference's
              own device for the letter spread, drawn from the crown's diamond. */}
          <svg className="cg-cat-lattice" viewBox="0 0 200 260" aria-hidden>
            <g fill="none" stroke="currentColor" strokeWidth="7">
              <path d="M-40 130l60-60 60 60-60 60z" />
              <path d="M40 190l60-60 60 60-60 60z" />
              <path d="M100 130l40 40 40-90" />
            </g>
          </svg>
        </div>
        <div className="cg-cat-intro-body">
          <h2 className="cg-cat-intro-ttl">{page.title}</h2>
          {/* Paragraphs, not a wall: the reference's letter is set in short
              blocks and reads as one because of the spacing between them. */}
          {(page.body ?? "").split(/\n{2,}/).map((para, i) =>
            para.trim() ? (
              <p key={i} className="cg-cat-intro-p">
                {para.trim()}
              </p>
            ) : null
          )}
          <div className="cg-cat-intro-sign">The {company} Team</div>
        </div>
        <PageFooter folio={folio} light mark={false} />
      </div>
    );
  }

  if (page.kind === "gallery") {
    const known = page.imageCode ? hasAsset(page.imageCode) : null;
    return (
      <div className="cg-cat-page cg-cat-p-gallery" style={style} data-kind="gallery">
        <Shot code={page.imageCode} quality={quality} missing={known === false} cover />
        <div className="cg-cat-gal-wash" />
        {page.title ? <div className="cg-cat-gal-ttl">{page.title}</div> : null}
        <PageFooter folio={folio} light />
      </div>
    );
  }

  const layout = sectionLayout(page);
  const stripKnown = page.imageCode ? hasAsset(page.imageCode) : null;
  return (
    <div
      className={`cg-cat-page cg-cat-p-section${page.imageCode ? " has-strip" : ""}`}
      style={style}
      data-kind="section"
    >
      <h2 className="cg-cat-sec-ttl">{page.title}</h2>
      <div className="cg-cat-grid" style={{ "--cat-cols": layout.cols } as React.CSSProperties}>
        {items.map((item) => (
          <ProductCard
            key={item.code}
            item={item}
            quality={quality}
            layout={layout}
            hasAsset={hasAsset}
          />
        ))}
      </div>
      {page.imageCode ? (
        <div className="cg-cat-strip">
          <Shot code={page.imageCode} quality={quality} missing={stripKnown === false} cover />
        </div>
      ) : null}
      <PageFooter folio={folio} />
    </div>
  );
});

// ------------------------------------------------------------------- stage

function CatalogPages({
  cat,
  itemsFor,
  quality,
  hasAsset,
  scale,
  selectedId,
  onSelect,
}: {
  cat: CatalogSpec;
  itemsFor: (page: CatalogPage) => CatalogItem[];
  quality: CatalogQuality;
  hasAsset: (code: string) => boolean | null;
  /** Absent = paint at true size (the PRINT tree). */
  scale?: number;
  selectedId?: string;
  onSelect?: (id: string) => void;
}) {
  const box = pageBox(cat.paper, cat.orientation);
  const px = box.unit === "in" ? 96 : 96 / 25.4;
  return (
    <>
      {cat.pages.map((page, i) => {
        const body = (
          <CatalogPageView
            cat={cat}
            page={page}
            index={i}
            items={itemsFor(page)}
            quality={quality}
            hasAsset={hasAsset}
          />
        );
        if (scale === undefined) return <div key={page.id}>{body}</div>;
        return (
          <div
            key={page.id}
            className={`cg-cat-slot${selectedId === page.id ? " is-sel" : ""}`}
            style={{
              width: box.w * px * scale,
              height: box.h * px * scale,
              "--cat-scale": scale,
            } as React.CSSProperties}
            onMouseDown={() => onSelect?.(page.id)}
          >
            {body}
          </div>
        );
      })}
    </>
  );
}

// ------------------------------------------------------------------- view

export const CatalogView = memo(function CatalogView({
  catalogs,
  onCatalogs,
  fields,
  fieldByKey,
  poolRows,
  filteredCodes,
}: {
  catalogs: CatalogSpec[];
  onCatalogs: (next: CatalogSpec[]) => void;
  fields: Field[];
  fieldByKey: Map<string, Field>;
  /** The WHOLE pool — a catalogue keeps painting what it names after the view
   *  is filtered, so the join must not run over the filtered rows. */
  poolRows: Row[];
  /** The codes the CURRENT filter kept — the "add what the view shows" door. */
  filteredCodes: string[];
}) {
  const [openId, setOpenId] = useState<string | null>(catalogs[0]?.id ?? null);
  const [pageId, setPageId] = useState<string | null>(null);
  const [search, setSearch] = useState("");
  const [picked, setPicked] = useState<Set<string>>(() => new Set());
  const [assetCodes, setAssetCodes] = useState<Set<string> | null>(null);
  const [editorialSlugs, setEditorialSlugs] = useState<Set<string> | null>(null);
  const edFileRef = useRef<HTMLInputElement | null>(null);
  const [printing, setPrinting] = useState<{ cat: CatalogSpec; quality: CatalogQuality } | null>(
    null
  );
  const [askQuality, setAskQuality] = useState<CatalogSpec | null>(null);
  const stageRef = useRef<HTMLDivElement | null>(null);
  const printRef = useRef<HTMLDivElement | null>(null);
  const [stageW, setStageW] = useState(0);

  const cat = catalogs.find((c) => c.id === openId) ?? null;
  const page = cat?.pages.find((p) => p.id === pageId) ?? null;

  // The manifest, once. `null` means "we do not know" and the designer stays
  // quiet — a false gap report sends the user hunting for images that are there.
  useEffect(() => {
    let live = true;
    void fetchAssetCodes().then((codes) => {
      if (live) setAssetCodes(codes);
    });
    void fetchEditorialSlugs().then((slugs) => {
      if (live) setEditorialSlugs(slugs);
    });
    return () => {
      live = false;
    };
  }, []);

  const hasAsset = useCallback(
    (code: string): boolean | null => {
      // `ed:` codes resolve against the EDITORIAL library (DEBT-5); everything
      // else stays a product code. Unknown-library (null) keeps the designer
      // quiet, same as the product manifest's own rule.
      const slug = editorialSlug(code);
      if (slug !== null) return editorialSlugs ? editorialSlugs.has(slug) : null;
      // ⭐ Wave-19 R7 — a `rec:` upload has NO manifest to check against, and inventing one
      // would be a per-tenant listing endpoint built for a designer badge. `null` is the
      // vocabulary this function already has for "I do not know", and it is the truthful
      // answer: an uploaded picture was validated at upload, so the far likelier state is that
      // it is there. Marking it missing would send the user hunting for a file that exists.
      if (code.startsWith(RECORD_IMAGE_PREFIX)) return null;
      return assetCodes ? assetCodes.has(code) : null;
    },
    [assetCodes, editorialSlugs]
  );

  useLayoutEffect(() => {
    const el = stageRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => setStageW(el.clientWidth));
    ro.observe(el);
    setStageW(el.clientWidth);
    return () => ro.disconnect();
  }, []);

  const byCode = useMemo(() => indexByCode(poolRows), [poolRows]);
  const itemsFor = useCallback(
    (p: CatalogPage) =>
      resolveItems(p.products ?? [], byCode, cat?.fields, formatDisplay, fieldByKey),
    [byCode, cat?.fields, fieldByKey]
  );

  const scale = useMemo(() => {
    if (!cat || !stageW) return 0.5;
    const box = pageBox(cat.paper, cat.orientation);
    const px = box.unit === "in" ? 96 : 96 / 25.4;
    return Math.min(1, Math.max(0.2, (stageW - 56) / (box.w * px)));
  }, [cat, stageW]);

  // ------------------------------------------------------------- mutation
  const write = useCallback(
    (next: CatalogSpec[]) => {
      onCatalogs(next);
    },
    [onCatalogs]
  );
  const patchCat = useCallback(
    (id: string, patch: Partial<CatalogSpec>) => {
      write(catalogs.map((c) => (c.id === id ? { ...c, ...patch } : c)));
    },
    [catalogs, write]
  );
  const patchPage = useCallback(
    (catId: string, pgId: string, patch: Partial<CatalogPage>) => {
      write(
        catalogs.map((c) =>
          c.id !== catId
            ? c
            : { ...c, pages: c.pages.map((p) => (p.id === pgId ? { ...p, ...patch } : p)) }
        )
      );
    },
    [catalogs, write]
  );

  const addCatalog = () => {
    if (catalogs.length >= MAX_CATALOGS) return;
    const made = newCatalog(`Catalog ${catalogs.length + 1}`, fields);
    write([...catalogs, made]);
    setOpenId(made.id);
    setPageId(made.pages[0]?.id ?? null);
  };

  const addPage = (kind: CatalogPageKind) => {
    if (!cat || cat.pages.length >= MAX_CATALOG_PAGES) return;
    const made = newPage(kind);
    patchCat(cat.id, { pages: [...cat.pages, made] });
    setPageId(made.id);
  };

  const movePage = (delta: number) => {
    if (!cat || !page) return;
    const i = cat.pages.findIndex((p) => p.id === page.id);
    const j = i + delta;
    if (i < 0 || j < 0 || j >= cat.pages.length) return;
    const pages = [...cat.pages];
    [pages[i], pages[j]] = [pages[j], pages[i]];
    patchCat(cat.id, { pages });
  };

  // ------------------------------------------------------------ the picker
  const matches = useMemo(() => {
    if (!cat) return [];
    const q = search.trim().toLowerCase();
    const nameKey = cat.fields?.name;
    const out: { code: string; label: string }[] = [];
    for (const r of poolRows) {
      const code = r[CATALOG_CODE_FIELD];
      if (typeof code !== "string" || !code) continue;
      const nameField = nameKey ? fieldByKey.get(nameKey) : undefined;
      const label = nameField ? formatDisplay(nameField, r[nameKey as string]) : "";
      if (q && !code.toLowerCase().includes(q) && !label.toLowerCase().includes(q)) continue;
      out.push({ code, label });
      if (out.length >= 200) break;
    }
    return out;
  }, [cat, poolRows, search, fieldByKey]);

  const commitCodes = (codes: string[]) => {
    if (!cat || !page) return;
    const res = addCodes(cat, page.id, codes);
    patchCat(cat.id, { pages: res.pages });
    setPicked(new Set());
    if (res.skippedCap || res.skippedDuplicate) {
      const parts: string[] = [];
      if (res.skippedDuplicate) parts.push(`${res.skippedDuplicate} already on the page`);
      if (res.skippedCap)
        parts.push(`${res.skippedCap} over the ${MAX_CATALOG_CODES}-product catalog limit`);
      setNote(`Added ${res.added}. Skipped ${parts.join("; ")}.`);
    } else {
      setNote(`Added ${res.added}.`);
    }
  };
  const [note, setNote] = useState("");

  // DEBT-5: one editorial master in, `ed:<slug>` bound to the open page. The server
  // owns validation (admin wall, charset, opens-as-an-image); this just reports honestly.
  const uploadEditorialFile = async (f: File) => {
    const slug = slugFromFilename(f.name);
    if (!slug) {
      setNote("That filename does not reduce to a usable name — rename the file first.");
      return;
    }
    setNote(`Uploading ${slug}…`);
    try {
      const stored = await uploadEditorial(slug, f);
      setEditorialSlugs(await fetchEditorialSlugs());
      if (cat && page) patchPage(cat.id, page.id, { imageCode: `${EDITORIAL_PREFIX}${stored}` });
      setNote(`Editorial image "${stored}" stored and bound to this page.`);
    } catch (e) {
      setNote(e instanceof Error ? e.message : "The upload failed.");
    }
  };

  // ------------------------------------------------------------- printing
  useEffect(() => {
    if (!printing) return;
    let cancelled = false;
    void (async () => {
      // One frame so the print tree is in the DOM before its images are awaited.
      await new Promise<void>((res) => requestAnimationFrame(() => res()));
      await awaitImages(printRef.current);
      if (cancelled) return;
      const drop = applyPrintCss(printing.cat);
      await printOnce();
      drop();
      if (!cancelled) setPrinting(null);
    })();
    return () => {
      cancelled = true;
    };
  }, [printing]);

  // ----------------------------------------------------------------- paint
  if (!catalogs.length)
    return (
      <div className="cg-cat cg-cat-blank">
        <div className="cg-cat-blank-in">
          <CrownMark className="cg-cat-blank-mark" />
          <h3>No catalogs yet</h3>
          <p>
            A catalog is a printable document built from this table&rsquo;s products — cover,
            introduction, product sections and full-page images. Print it to PDF from the browser
            when it is ready.
          </p>
          <button type="button" className="cg-btn cg-btn--primary" onClick={addCatalog}>
            <IconPlus /> New catalog
          </button>
        </div>
      </div>
    );

  const used = cat ? codesUsed(cat) : 0;

  return (
    <div className="cg-cat">
      {/* ------------------------------------------------------------ rail */}
      <aside className="cg-cat-rail">
        <div className="cg-cat-rail-head">
          <span>Catalogs</span>
          <button
            type="button"
            className="cg-cat-icon-btn"
            title={
              catalogs.length >= MAX_CATALOGS
                ? `A view holds ${MAX_CATALOGS} catalogs.`
                : "New catalog"
            }
            aria-label="New catalog"
            disabled={catalogs.length >= MAX_CATALOGS}
            onClick={addCatalog}
          >
            <IconPlus />
          </button>
        </div>
        <ul className="cg-cat-list">
          {catalogs.map((c) => (
            <li key={c.id}>
              <button
                type="button"
                className={`cg-cat-item${c.id === openId ? " is-open" : ""}`}
                onClick={() => {
                  setOpenId(c.id);
                  setPageId(c.pages[0]?.id ?? null);
                }}
              >
                <span className="cg-cat-item-name">{c.name || "Untitled catalog"}</span>
                <span className="cg-cat-item-meta">
                  {c.pages.length} {c.pages.length === 1 ? "page" : "pages"}
                </span>
              </button>
            </li>
          ))}
        </ul>
        {cat ? (
          <>
            <div className="cg-cat-rail-head cg-cat-rail-sub">
              <span>Pages</span>
              <span className="cg-cat-count">
                {cat.pages.length}/{MAX_CATALOG_PAGES}
              </span>
            </div>
            <ul className="cg-cat-pagelist">
              {cat.pages.map((p, i) => (
                <li key={p.id}>
                  <button
                    type="button"
                    className={`cg-cat-pageitem${p.id === pageId ? " is-sel" : ""}`}
                    onClick={() => setPageId(p.id)}
                  >
                    <span className="cg-cat-pageno">{i + 1}</span>
                    <span className="cg-cat-pagettl">
                      {p.title || PAGE_KIND_LABELS[p.kind]}
                    </span>
                    <span className="cg-cat-pagekind">{PAGE_KIND_LABELS[p.kind]}</span>
                  </button>
                </li>
              ))}
            </ul>
            <div className="cg-cat-addrow">
              {CATALOG_PAGE_KINDS.map((k) => (
                <button
                  key={k}
                  type="button"
                  className="cg-btn"
                  disabled={cat.pages.length >= MAX_CATALOG_PAGES}
                  onClick={() => addPage(k)}
                >
                  <IconPlus /> {PAGE_KIND_LABELS[k]}
                </button>
              ))}
            </div>
          </>
        ) : null}
      </aside>

      {/* ----------------------------------------------------------- stage */}
      <div className="cg-cat-stage" ref={stageRef}>
        {cat ? (
          <>
            <div className="cg-cat-bar">
              <input
                className="cg-input cg-cat-name"
                value={cat.name}
                maxLength={CATALOG_NAME_MAX}
                aria-label="Catalog name"
                onChange={(e) => patchCat(cat.id, { name: e.target.value })}
              />
              <select
                className="cg-select"
                aria-label="Paper size"
                value={cat.paper}
                onChange={(e) => patchCat(cat.id, { paper: e.target.value as CatalogPaper })}
              >
                {CATALOG_PAPERS.map((p) => (
                  <option key={p} value={p}>
                    {PAPER_LABELS[p]}
                  </option>
                ))}
              </select>
              <select
                className="cg-select"
                aria-label="Orientation"
                value={cat.orientation}
                onChange={(e) =>
                  patchCat(cat.id, { orientation: e.target.value as CatalogOrientation })
                }
              >
                {CATALOG_ORIENTATIONS.map((o) => (
                  <option key={o} value={o}>
                    {o === "portrait" ? "Portrait" : "Landscape"}
                  </option>
                ))}
              </select>
              <span className="cg-cat-budget" title="Products placed across this catalog's pages">
                {used}/{MAX_CATALOG_CODES} products
              </span>
              <button
                type="button"
                className="cg-btn cg-btn--primary cg-cat-print"
                onClick={() => setAskQuality(cat)}
              >
                <IconPrint /> Print
              </button>
            </div>
            <div className="cg-cat-scroll">
              <CatalogPages
                cat={cat}
                itemsFor={itemsFor}
                quality="web"
                hasAsset={hasAsset}
                scale={scale}
                selectedId={pageId ?? undefined}
                onSelect={setPageId}
              />
            </div>
          </>
        ) : (
          <div className="cg-mode-empty">Pick a catalog to open it.</div>
        )}
      </div>

      {/* ------------------------------------------------------- inspector */}
      <aside className="cg-cat-insp">
        {cat && page ? (
          <>
            <div className="cg-cat-insp-head">
              <span>{PAGE_KIND_LABELS[page.kind]}</span>
              <span className="cg-cat-insp-tools">
                <button
                  type="button"
                  className="cg-cat-icon-btn"
                  aria-label="Move page up"
                  onClick={() => movePage(-1)}
                >
                  <IconUp />
                </button>
                <button
                  type="button"
                  className="cg-cat-icon-btn"
                  aria-label="Move page down"
                  onClick={() => movePage(1)}
                >
                  <IconDown />
                </button>
                <button
                  type="button"
                  className="cg-cat-icon-btn cg-cat-del"
                  aria-label="Delete page"
                  onClick={() =>
                    patchCat(cat.id, { pages: cat.pages.filter((p) => p.id !== page.id) })
                  }
                >
                  <IconTrash />
                </button>
              </span>
            </div>

            <label className="cg-cat-fld">
              <span>Title</span>
              <input
                className="cg-input"
                value={page.title ?? ""}
                onChange={(e) => patchPage(cat.id, page.id, { title: e.target.value })}
              />
            </label>

            {page.kind === "cover" || page.kind === "intro" ? (
              <label className="cg-cat-fld">
                <span>{page.kind === "cover" ? "Second line" : "Body"}</span>
                <textarea
                  className="cg-input cg-cat-area"
                  rows={page.kind === "cover" ? 2 : 9}
                  value={page.body ?? ""}
                  onChange={(e) => patchPage(cat.id, page.id, { body: e.target.value })}
                />
              </label>
            ) : null}

            {page.kind !== "intro" ? (
              <>
                <label className="cg-cat-fld">
                  <span>{page.kind === "section" ? "Footer image (code)" : "Image (code)"}</span>
                  <input
                    className="cg-input"
                    value={page.imageCode ?? ""}
                    placeholder="e.g. CRY-2003 or ed:showroom"
                    onChange={(e) => patchPage(cat.id, page.id, { imageCode: e.target.value })}
                  />
                  {page.imageCode && hasAsset(page.imageCode) === false ? (
                    <em className="cg-cat-warn">
                      {editorialSlug(page.imageCode) !== null
                        ? "No editorial image is stored under this name — the page will print an empty frame."
                        : "No image is archived under this code — the page will print an empty frame."}
                    </em>
                  ) : (
                    <em className="cg-cat-hint">
                      Fills the width edge to edge, so a wide photograph reads best here. A cut-out
                      product render will be cropped and enlarged to fill it.
                    </em>
                  )}
                </label>
                <label className="cg-cat-fld">
                  <span>Editorial library</span>
                  <select
                    className="cg-select"
                    value={editorialSlug(page.imageCode ?? "") ?? ""}
                    onChange={(e) =>
                      patchPage(cat.id, page.id, {
                        imageCode: e.target.value ? `${EDITORIAL_PREFIX}${e.target.value}` : "",
                      })
                    }
                  >
                    <option value="">(use a product code above)</option>
                    {[...(editorialSlugs ?? [])].sort().map((s) => (
                      <option key={s} value={s}>
                        {s}
                      </option>
                    ))}
                  </select>
                  <button
                    type="button"
                    className="cg-btn"
                    onClick={() => edFileRef.current?.click()}
                  >
                    Upload a photograph…
                  </button>
                  <input
                    ref={edFileRef}
                    type="file"
                    accept="image/png,image/jpeg"
                    style={{ display: "none" }}
                    onChange={(e) => {
                      const f = e.target.files?.[0];
                      if (f) void uploadEditorialFile(f);
                      e.target.value = "";
                    }}
                  />
                  <em className="cg-cat-hint">
                    Lifestyle photography without a SKU lives here. Storing a new one needs an
                    admin login; everyone can pick from the library.
                  </em>
                </label>
              </>
            ) : null}

            {page.kind === "section" ? (
              <>
                <div className="cg-cat-insp-sub">Layout</div>
                <div className="cg-cat-row">
                  <label className="cg-cat-fld cg-cat-fld-inline">
                    <span>Columns</span>
                    <select
                      className="cg-select"
                      value={sectionLayout(page).cols}
                      onChange={(e) =>
                        patchPage(cat.id, page.id, {
                          layout: { ...page.layout, cols: Number(e.target.value) },
                        })
                      }
                    >
                      {CATALOG_COLS.map((c) => (
                        <option key={c} value={c}>
                          {c}
                        </option>
                      ))}
                    </select>
                  </label>
                </div>
                {(
                  [
                    ["showPack", "Pack line", false],
                    ["showColor", "Colour", false],
                    ["showPrice", "Price", true],
                  ] as const
                ).map(([key, label, optIn]) => {
                  const l = sectionLayout(page);
                  const on = key === "showPrice" ? l.showPrice : key === "showPack" ? l.showPack : l.showColor;
                  return (
                    <label key={key} className="cg-check-row">
                      <input
                        type="checkbox"
                        checked={on}
                        onChange={() => {
                          // Literal-only storage: the toggle writes the opt-out (or the
                          // opt-in) and DELETES the key to go back to the default, so the
                          // default has exactly one spelling on the wire.
                          const next = { ...page.layout };
                          if (optIn) {
                            if (on) delete next.showPrice;
                            else next.showPrice = true;
                          } else if (key === "showPack") {
                            if (on) next.showPack = false;
                            else delete next.showPack;
                          } else {
                            if (on) next.showColor = false;
                            else delete next.showColor;
                          }
                          patchPage(cat.id, page.id, {
                            layout: Object.keys(next).length ? next : undefined,
                          });
                        }}
                      />
                      <span>{label}</span>
                    </label>
                  );
                })}

                <div className="cg-cat-insp-sub">
                  Products
                  <span className="cg-cat-count">{page.products?.length ?? 0} on this page</span>
                </div>
                <div className="cg-cat-picked">
                  {(page.products ?? []).map((code) => (
                    <span key={code} className="cg-cat-chip">
                      {code}
                      <button
                        type="button"
                        aria-label={`Remove ${code}`}
                        onClick={() =>
                          patchPage(cat.id, page.id, {
                            products: (page.products ?? []).filter((c) => c !== code),
                          })
                        }
                      >
                        &times;
                      </button>
                    </span>
                  ))}
                </div>
                <button
                  type="button"
                  className="cg-btn cg-cat-addfiltered"
                  disabled={!filteredCodes.length}
                  onClick={() => commitCodes(filteredCodes)}
                >
                  <IconPlus /> Add the {filteredCodes.length} products this view shows
                </button>
                <input
                  className="cg-input"
                  placeholder="Search products by SKU or name"
                  value={search}
                  onChange={(e) => setSearch(e.target.value)}
                />
                <div className="cg-cat-results">
                  {matches.map((m) => (
                    <label key={m.code} className="cg-check-row">
                      <input
                        type="checkbox"
                        checked={picked.has(m.code)}
                        onChange={() =>
                          setPicked((prev) => {
                            const next = new Set(prev);
                            if (next.has(m.code)) next.delete(m.code);
                            else next.add(m.code);
                            return next;
                          })
                        }
                      />
                      <span className="cg-cat-res-code">{m.code}</span>
                      <span className="cg-cat-res-name">{m.label}</span>
                    </label>
                  ))}
                  {/* The 200 is STATED, never silent: a search that stops early has
                      to say so or the user reads absence as "we do not stock it". */}
                  {matches.length >= 200 ? (
                    <div className="cg-cat-more">
                      Showing the first 200 matches — narrow the search to see the rest.
                    </div>
                  ) : null}
                </div>
                <button
                  type="button"
                  className="cg-btn cg-btn--primary"
                  disabled={!picked.size}
                  onClick={() => commitCodes([...picked])}
                >
                  Add {picked.size} selected
                </button>
                {note ? <div className="cg-cat-note">{note}</div> : null}
              </>
            ) : null}

            <div className="cg-cat-insp-sub">Brand</div>
            <div className="cg-cat-row">
              <label className="cg-cat-fld cg-cat-fld-inline">
                <span>Primary</span>
                <input
                  type="color"
                  className="cg-cat-swatch"
                  value={cat.brand?.primary ?? DEFAULT_BRAND.primary}
                  onChange={(e) =>
                    patchCat(cat.id, { brand: { ...cat.brand, primary: e.target.value } })
                  }
                />
              </label>
              <label className="cg-cat-fld cg-cat-fld-inline">
                <span>Accent</span>
                <input
                  type="color"
                  className="cg-cat-swatch"
                  value={cat.brand?.accent ?? DEFAULT_BRAND.accent}
                  onChange={(e) =>
                    patchCat(cat.id, { brand: { ...cat.brand, accent: e.target.value } })
                  }
                />
              </label>
            </div>
            <label className="cg-cat-fld">
              <span>Company</span>
              <input
                className="cg-input"
                value={cat.brand?.company ?? ""}
                placeholder={DEFAULT_BRAND.company}
                onChange={(e) =>
                  patchCat(cat.id, { brand: { ...cat.brand, company: e.target.value } })
                }
              />
            </label>

            <div className="cg-cat-insp-sub">Listing columns</div>
            {(
              [
                ["name", "Description"],
                ["pack", "Pack line"],
                ["color", "Colour"],
                ["price", "Price"],
              ] as const
            ).map(([slot, label]) => (
              <label key={slot} className="cg-cat-fld cg-cat-fld-inline">
                <span>{label}</span>
                <select
                  className="cg-select"
                  value={cat.fields?.[slot] ?? ""}
                  onChange={(e) => {
                    const next = { ...cat.fields };
                    if (e.target.value) next[slot] = e.target.value;
                    else delete next[slot];
                    patchCat(cat.id, { fields: Object.keys(next).length ? next : undefined });
                  }}
                >
                  {/* An explicit empty option — the wave-7 trap: a <select> whose value
                      is not among its options renders the FIRST one, so "unbound" needs a
                      row of its own rather than being the absence of one. */}
                  <option value="">Not shown</option>
                  {fields.map((f) => (
                    <option key={f.key} value={f.key}>
                      {f.label}
                    </option>
                  ))}
                </select>
              </label>
            ))}
            <button
              type="button"
              className="cg-btn"
              onClick={() => {
                const guess = autoBind(fields);
                patchCat(cat.id, { fields: Object.keys(guess).length ? guess : undefined });
              }}
            >
              Guess from this table
            </button>
            <div className="cg-cat-insp-foot">
              <button
                type="button"
                className="cg-btn cg-btn--danger"
                onClick={() => {
                  const next = catalogs.filter((c) => c.id !== cat.id);
                  write(next);
                  setOpenId(next[0]?.id ?? null);
                  setPageId(null);
                }}
              >
                <IconTrash /> Delete this catalog
              </button>
            </div>
          </>
        ) : (
          <div className="cg-cat-insp-blank">Select a page to edit it.</div>
        )}
      </aside>

      {/* --------------------------------------------------- quality prompt */}
      {askQuality ? (
        <div className="cg-cat-modal-wrap" role="dialog" aria-modal="true" aria-label="Print quality">
          <div className="cg-cat-modal">
            <h3>Print &ldquo;{askQuality.name || "Untitled catalog"}&rdquo;</h3>
            <p>
              Your browser&rsquo;s print dialog opens next — choose &ldquo;Save as PDF&rdquo; there.
              Pick the image quality first.
            </p>
            <button
              type="button"
              className="cg-cat-choice"
              onClick={() => {
                setPrinting({ cat: askQuality, quality: "print" });
                setAskQuality(null);
              }}
            >
              <strong>High quality</strong>
              <em>Original master images. Slower, and a much larger PDF — use this for print.</em>
            </button>
            <button
              type="button"
              className="cg-cat-choice"
              onClick={() => {
                setPrinting({ cat: askQuality, quality: "web" });
                setAskQuality(null);
              }}
            >
              <strong>Standard</strong>
              <em>The same images you see here. Fast, small file — fine for a proof or email.</em>
            </button>
            <button type="button" className="cg-btn" onClick={() => setAskQuality(null)}>
              Cancel
            </button>
          </div>
        </div>
      ) : null}

      {/* ------------------------------------------------------ print tree */}
      {printing
        ? createPortal(
            <div className="cg-cat-print-portal" ref={printRef}>
              <CatalogPages
                cat={printing.cat}
                itemsFor={itemsFor}
                quality={printing.quality}
                hasAsset={hasAsset}
              />
            </div>,
            document.body
          )
        : null}
    </div>
  );
});

export default CatalogView;
