// Shared design tokens, placeholders, and product data for THE EPIZODE concepts.

const EPZ_COLORS = {
  photoBg: '#FAF6EE',
  burgundy: '#6E1A21',
  burgundyDeep: '#4E1117',
  burgundyInk: '#2C0C10',
  cream: '#EFE7D6',
  creamSoft: '#E7DDC7',
  paper: '#F6F1E4',
  ink: '#14110E',
  inkSoft: '#3A332C',
  mute: '#8A8074',
  line: '#1F1B17',
  hairline: 'rgba(20,17,14,0.15)',
};

const EPZ_FONTS = {
  display: '"Cormorant Garamond", "Old Standard TT", Georgia, serif',
  displayAlt: '"Italiana", "Cormorant Garamond", serif',
  body: '"Manrope", -apple-system, system-ui, sans-serif',
  mono: '"JetBrains Mono", "IBM Plex Mono", ui-monospace, monospace',
};

// ─── DATA MODEL ──────────────────────────────────────────────────────────────
// IMPORTANT: no film names, director names, or character/actor names on the site
// (copyright + likeness). Episodes are neutral moods; articles describe garments.
//
// Structure: 4 EPISODES (looks, one per film reference) → each holds ARTICLES (SKUs).
// Some articles (pyjamas) have VARIANTS (топ / брюки / комплект) with their own price;
// the set ("комплект") is cheaper than buying the pieces apart.

// ─── Поиск с опечатками ──────────────────────────────────────────────────────
// epzSearchArticles('платте') найдёт платья: нормализация (ё→е, e→е и т.п.),
// совпадение по началу слова и расстояние Левенштейна (1 ошибка на короткое
// слово, 2 — на длинное). Используется в обоих поисках шапки.
function epzSearchNorm(s) {
  return String(s).toLowerCase()
    .replace(/ё/g, 'е').replace(/й/g, 'и')
    .replace(/[^a-zа-я0-9\s-]/g, ' ').replace(/\s+/g, ' ').trim();
}
function epzLev(a, b, max) {
  if (Math.abs(a.length - b.length) > max) return max + 1;
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
  for (let i = 1; i <= a.length; i++) {
    const cur = [i];
    let rowMin = i;
    for (let j = 1; j <= b.length; j++) {
      cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
      if (cur[j] < rowMin) rowMin = cur[j];
    }
    if (rowMin > max) return max + 1;
    prev = cur;
  }
  return prev[b.length];
}
function epzTokenMatches(qw, word) {
  if (word.startsWith(qw)) return true;                    // 'плат' → 'платье', 'платок'
  const max = qw.length <= 4 ? 1 : 2;                      // допуск опечаток
  if (qw.length >= 4 && epzLev(qw, word, max) <= max) return true; // 'платте' → 'платье'
  // опечатка в начале слова при неполном вводе: 'пладь' → 'платье'
  if (qw.length >= 4 && word.length > qw.length && epzLev(qw, word.slice(0, qw.length), 1) <= 1) return true;
  return false;
}
function epzSearchArticles(query) {
  const qWords = epzSearchNorm(query).split(' ').filter(Boolean);
  if (!qWords.length) return [];
  return epzHomeShowcase().filter((a) => {
    const ep = epzEpisode(a.episode);
    const tags = [].concat(ep.searchTags || [], a.searchTags || []).join(' ');
    const hay = epzSearchNorm(
      a.title + ' ' + (a.short || '') + ' ' + (a.homeTitle || '') + ' ' + a.type + ' ' +
      a.fabric + ' ' + ep.title + ' ' + ep.scene + ' ' + tags
    ).split(' ').filter(Boolean);
    // каждое слово запроса должно найтись хоть в одном слове описания
    return qWords.every((qw) => hay.some((w) => epzTokenMatches(qw, w)));
  });
}

const EPZ_EPISODES = [
  // searchTags — СКРЫТЫЕ ключевые слова для поиска (НЕ выводятся в UI): имена актрис,
  // героев, названия фильмов/сериалов. Нужны только чтобы поиск находил образы.
  { id: '001', title: 'Катрин', scene: 'Сцена допроса',    timecode: '00:42:18', palette: '#F2EEE3', campaignContain: true,
    setPrice: 55000, setLabel: 'Белое платье и шерстяной тренч',
    searchTags: ['основной инстинкт', 'basic instinct', 'шерон стоун', 'шерон', 'стоун', 'sharon stone', 'катрин трамелл', 'catherine tramell'] },
  { id: '002', title: 'Эльвира', scene: 'Финальная сцена',  timecode: '02:38:04', palette: '#0E3A2A', campaignContain: true,
    searchTags: ['лицо со шрамом', 'scarface', 'эльвира', 'elvira', 'мишель пфайффер', 'michelle pfeiffer', 'тони монтана', 'tony montana', 'аль пачино', 'al pacino'] },
  { id: '003', title: 'Хацумомо', scene: 'Сцена в поле',     timecode: '01:14:50', palette: '#1A1612', image: '/assets/hero-hatsumomo.jpg',
    searchTags: ['мемуары гейши', 'memoirs of a geisha', 'гейша', 'geisha', 'хацумомо', 'hatsumomo', 'гун ли', 'gong li'] },
  { id: '004', title: 'Тони',   scene: 'Сцена на берегу',  timecode: '00:27:32', palette: '#B89A6E',
    searchTags: ['клан сопрано', 'сопрано', 'sopranos', 'the sopranos', 'тони сопрано', 'tony soprano', 'джеймс гандольфини', 'james gandolfini'] },
];

const EPZ_ARTICLES = [
  {
    id: '001-1', episode: '001', title: 'Платье Catherine', short: 'Платье Catherine', type: 'Платье',
    color: 'Молочный', colorHex: '#ECE4D2',
    fabric: 'Вискоза', price: 29000, image: '/assets/art-001-dress.jpg',
    gallery: [
      { src: '/assets/art-001-dress.jpg', label: 'обтравка', cover: true },
      { src: '/assets/art-001-dress-back.jpg', label: 'спина' },
    ],
    hoverImage: '/assets/art-001-dress-back.jpg',
    sizes: ['S', 'M'], preorder: false, scene: 'Сцена на вилле', timecode: '00:21:08',
    desc: '«У меня диплом психолога. Игры — это весело»', descEn: '«I have a degree in psychology. Games are fun»',
    lead: 'Пикантная особенность платья — можно случайно (нет) забыть надеть нижнее бельё',
    tech: 'Короткое платье с американской проймой. Высокий воротник-стойка, чистые линии кроя и открытые плечи создают лаконичный и в то же время чувственный образ. Приталенный силуэт мягко повторяет линии фигуры благодаря продуманным рельефным швам, а длина мини делает платье эффектным и смелым. Сзади — потайная застёжка-молния для идеальной посадки.',
    compositionNote: 'Вискоза дарит мягкость, лёгкий матовый блеск и приятную текстуру, полиэстер обеспечивает прочность и сохранение формы, а эластан добавляет эластичность и идеальную посадку по фигуре.',
    bundleNote: 'Платье и тренч созданы как единый образ: платье мини и объёмный тренч-кейп идеально дополняют друг друга — от утончённой женственности до драматичного нуарного шика в одном движении. Вместе они складываются в законченный, узнаваемый силуэт. Рекомендуем носить в комплекте.',
    features: ['Высокий воротник-стойка', 'Американская пройма', 'Рельефные швы, подчёркивающие фигуру', 'Потайная молния на спине', 'Длина мини'],
    care: [
      'Рекомендована сухая (химическая) чистка',
      'Ручная стирка в прохладной воде до 30 °C с мягким средством, если стирка необходима',
      'Не выкручивать и не тереть — отжимать аккуратно',
      'Сушить в расправленном виде вдали от прямого солнца и нагревательных приборов',
      'Гладить при низкой температуре через ткань или отпаривать',
    ],
    details: [['Состав', '65% вискоза / 29% полиэстер / 6% эластан'], ['Силуэт', 'приталенный'], ['Длина', 'мини'], ['Застёжка', 'потайная молния сзади'], ['Цвет', 'молочный']],
  },
  {
    id: '001-2', episode: '001', title: 'Тренч Catherine', short: 'Тренч Catherine', type: 'Тренч',
    color: 'Молочный', colorHex: '#ECE4D2',
    fabric: 'Шерсть', price: 32000, image: '/assets/art-001-cardigan.jpg',
    gallery: [
      { src: '/assets/art-001-cardigan.jpg', label: 'обтравка', cover: true },
      { src: '/assets/art-001-cardigan-back.jpg', label: 'спина' },
      { src: '/assets/art-001-cardigan-side.jpg', label: 'бок' },
    ],
    hoverImage: '/assets/art-001-cardigan-back.jpg',
    sizes: ['ONE SIZE'], preorder: false, scene: 'Сцена допроса', timecode: '00:23:31',
    desc: '«Я писательница. Я использую людей для своих книг»', descEn: '«I’m a writer. I use people for what I write»',
    lead: 'Мне скрывать нечего. Дальше — работа вашего воображения',
    tech: 'Объёмный тренч-кейп с выразительным силуэтом. Мягкая струящаяся форма, крупный отложной воротник и свободный крой создают тот самый узнаваемый нуарный шик. Модель без застёжки, свободно запахивается, мягко драпируется по фигуре и одинаково эффектно смотрится наброшенной на плечи или надетой в рукава. Сзади ткань собирается в мягкие фалды, которые струятся при каждом движении, создавая выразительный объём и плавную линию спины.',
    compositionNote: 'Шерсть дарит тепло и держит форму, вискоза добавляет мягкость и благородный матовый блеск, полиэстер обеспечивает износостойкость и сохранение силуэта.',
    features: ['Объёмный крой oversize', 'Крупный воротник', 'Свободный силуэт без застёжки', 'Струящиеся фалды на спине', 'Два способа носки: в рукава или наброшенным на плечи'],
    care: [
      'Рекомендована сухая (химическая) чистка',
      'Ручная стирка в прохладной воде до 30 °C с мягким средством для шерсти, если стирка необходима',
      'Не выкручивать и не тереть — отжимать аккуратно, без усилий',
      'Сушить в расправленном виде на горизонтальной поверхности, вдали от прямого солнца и нагревательных приборов',
      'Гладить при низкой температуре через ткань или отпаривать',
      'Хранить на широких плечиках, чтобы сохранить форму воротника и фалд',
    ],
    bundleNote: 'Платье и тренч созданы как единый образ: платье мини и объёмный тренч-кейп идеально дополняют друг друга — от утончённой женственности до драматичного нуарного шика в одном движении. Вместе они складываются в законченный, узнаваемый силуэт. Рекомендуем носить в комплекте.',
    details: [['Состав', '32% овечья шерсть / 41% вискоза / 27% полиэстер'], ['Силуэт', 'оверсайз'], ['Воротник', 'крупный отложной'], ['Застёжка', 'без застёжки, запах'], ['Цвет', 'молочный (не в тон с платьем)']],
  },
  {
    id: '002-1', episode: '002', title: 'Платье Elvira', short: 'Платье Elvira', type: 'Платье в пол',
    color: 'Морская волна', colorHex: '#1C4C42',
    fabric: 'Натуральный шёлк', price: 59000, image: '/assets/elvira-dress-front.jpg',
    gallery: [
      { src: '/assets/elvira-dress-front.jpg', label: 'обтравка', cover: true },
      { src: '/assets/elvira-dress-back.jpg', label: 'деталь' },
    ],
    hoverImage: '/assets/elvira-dress-back.jpg',
    sizes: ['XS–S', 'S–M'], preorder: true, scene: 'Сцена у лифта', timecode: '00:33:12',
    desc: '«Ничто не сравнится с излишеством»', descEn: '«Nothing exceeds like excess»',
    lead: '«Ничто не сравнится с излишеством»',
    tech: 'Длинное вечернее платье из 100% натурального шёлка глубокого оттенка морская волна. Струящийся силуэт, открытая спина, лиф на тонких бретелях с V-образным вырезом мягко подчёркивает декольте, а открытая спина с перекрещёнными бретелями добавляет образу изящества и лёгкой дерзости. Бретели регулируются по длине. Чуть приталенный верх плавно переходит в расклешённую юбку с высоким разрезом, которая красиво струится и раскрывается при каждом шаге. Клинья — один спереди и два сзади — формируют плавный расклёш. Сзади — потайная застёжка-молния для безупречной посадки.',
    compositionNote: 'Натуральный шёлк дарит благородный блеск, лёгкость и приятную прохладу при носке, мягко повторяя движения тела.',
    features: ['Длина макси', 'Лиф с V-образным вырезом', 'Открытая спина с перекрещёнными регулируемыми бретелями', 'Приталенный силуэт', 'Расклешённая юбка с высоким разрезом', 'Клинья: один спереди, два сзади', 'Потайная молния на спине'],
    care: [
      'Рекомендована только сухая (химическая) чистка',
      'При необходимости — деликатная ручная стирка в прохладной воде с мягким средством для шёлка',
      'Не замачивать, не выкручивать и не тереть — отжимать аккуратно через полотенце',
      'Сушить в расправленном виде вдали от прямого солнца и нагревательных приборов',
      'Гладить с изнаночной стороны при низкой температуре или отпаривать через ткань',
      'Хранить на плечиках в чехле, вдали от прямого света',
    ],
    details: [['Состав', '100% натуральный шёлк'], ['Длина', 'макси'], ['Лиф', 'V-образный вырез'], ['Бретели', 'перекрещённые, регулируемые'], ['Юбка', 'расклешённая, высокий разрез'], ['Застёжка', 'потайная молния сзади'], ['Цвет', 'Морская волна']],
  },
  {
    id: '002-2', episode: '002', title: 'Комплект Elvira из шёлка', short: 'Комплект Elvira из шёлка', type: 'Комплект',
    variantNames: { top: 'Топ Elvira из шёлка', bottom: 'Брюки Elvira из шёлка', set: 'Комплект Elvira из шёлка' },
    variantImages: { top: '/assets/art-002-silk-top.jpg', bottom: '/assets/art-002-silk-pants.jpg', set: '/assets/art-002-pyjama-silk.jpg' },
    fabric: 'Натуральный шёлк', image: '/assets/art-002-pyjama-silk.jpg',
    sizes: ['XS–S', 'S–M'], preorder: true, scene: 'Сцена в джакузи', timecode: '01:51:57',
    desc: '«Я дам себе всё, чего я заслуживаю»', descEn: '«I’m going to give myself everything I deserve»',
    lead: 'Для той, кто живёт дорого и не объясняется. Хочешь — смотри. Трогать всё равно нельзя',
    variantLeads: { set: '«Я дам себе всё, чего я заслуживаю»' },
    tech: 'Комплект из натурального шёлка: топ на тонких регулируемых бретелях и брюки-палаццо с высокой посадкой. Можно взять раздельно или комплектом.',
    details: [['Состав', '100% шёлк'], ['Топ', 'на тонких бретелях'], ['Брюки', 'палаццо, высокая посадка'], ['Пояс', 'на резинке'], ['Комбинации', 'топ / брюки / комплект']],
    variants: [
      { key: 'top',    label: 'Топ на бретелях', price: 24000 },
      { key: 'bottom', label: 'Брюки-палаццо', price: 22000 },
      { key: 'set',    label: 'Комплект',     price: 40000 },
    ],
  },
  {
    id: '002-3', episode: '002', title: 'Комплект Elvira из вискозы', short: 'Комплект Elvira из вискозы', type: 'Комплект',
    variantNames: { top: 'Топ Elvira из вискозы', bottom: 'Брюки Elvira из вискозы', set: 'Комплект Elvira из вискозы' },
    variantImages: { top: '/assets/art-002-cupro-top-clean.jpg', bottom: '/assets/art-002-cupro-pants.jpg', set: '/assets/art-002-pyjama-cupro.jpg' },
    fabric: 'Вискоза', image: '/assets/art-002-pyjama-cupro.jpg',
    sizes: ['XS–S', 'S–M'], preorder: false, scene: 'Сцена в джакузи', timecode: '01:51:57',
    desc: '«Тебе нужно гораздо больше, чем я, Тони»', descEn: '«You need a lot more than me, Tony»',
    lead: 'Будет сложно не собрать взгляды, чтобы все остальные почувствовали себя массовкой',
    tech: 'Комплект из вискозы (купра) с матовой поверхностью и лёгким блеском: топ на тонких бретелях и брюки-палаццо. Можно взять раздельно или комплектом.',
    details: [['Состав', '100% вискоза'], ['Топ', 'на тонких бретелях'], ['Брюки', 'палаццо, высокая посадка'], ['Пояс', 'на резинке'], ['Комбинации', 'топ / брюки / комплект']],
    variants: [
      { key: 'top',    label: 'Топ на бретелях', price: 21000 },
      { key: 'bottom', label: 'Брюки-палаццо', price: 19000 },
      { key: 'set',    label: 'Комплект',     price: 35000 },
    ],
  },
  {
    id: '003-1', episode: '003', title: 'Платье Hatsumomo', short: 'Платье Hatsumomo', type: 'Платье-рубашка',
    color: 'Чёрный', colorHex: '#1A1614',
    fabric: 'Натуральный шёлк', price: 45000, image: '/assets/art-003-1.jpg',
    gallery: [
      { src: '/assets/art-003-1.jpg', label: 'застёгнуто' },
      { src: '/assets/art-003-campaign2.jpg', label: 'кампейн', cover: true },
      { src: '/assets/art-003-2.jpg', label: 'распахнуто' },
      { src: '/assets/art-003-3.jpg', label: 'спинка' },
      { src: '/assets/art-003-4.jpg', label: 'профиль' },
      { src: '/assets/art-003-5.jpg', label: 'три четверти' },
    ],
    hoverImage: '/assets/art-003-campaign2.jpg', hoverCover: true,
    sizes: ['ONE SIZE'], preorder: false, scene: 'Образ гейши', timecode: '', motif: true, model: 'Малика Джураева',
    desc: '«Кому нужна слива, если кто-то уже откусил от неё?»', descEn: '«Who wants a plum when someone has already had a bite?»',
    lead: '«Шёлк цвета полуночи, в котором прячется характер, где красота — и подарок, и предупреждение»',
    tech: 'Платье-рубашка из натурального шёлка с жаккардовым принтом. Свободный силуэт, рубашечный воротник, застёжка на пуговицы, съёмный пояс.',
    details: [['Состав', '100% шёлк'], ['Силуэт', 'свободный'], ['Воротник', 'рубашечный'], ['Застёжка', 'пуговицы'], ['Пояс', 'съёмный']],
  },
  {
    id: '004-1', episode: '004', title: 'Платок Tony', short: 'Платок Tony', type: 'Платок',
    fabric: 'Натуральный шёлк', price: 7000, image: '/assets/art-004-scarf-tony.jpg',
    gallery: [
      { src: '/assets/art-004-scarf-tony.jpg', label: 'обтравка', cover: true },
    ],
    sizes: ['ONE SIZE'], preorder: false, scene: 'Итало-американская мафия', timecode: '', motif: true,
    desc: '«Даже сломанные часы дважды в сутки показывают верное время»', descEn: '«Even a broken clock is right twice a day»',
    lead: '«Слабость настоящего мафиози — скользящий по плечам шёлк и… утки»',
    tech: 'Пляжный платок из лёгкого шёлкового шифона с авторским принтом. Размер 90 × 90 см, обработка краёв вручную.',
    details: [['Состав', '100% шёлк'], ['Размер', '90 × 90 см'], ['Край', 'ручная обработка'], ['Принт', 'авторский']],
  },
];

// ─── Helpers ─────────────────────────────────────────────────────────────────
function epzFmt(n) {
  return (typeof n === 'number' && isFinite(n) ? n : 0).toLocaleString('ru-RU') + ' ₽';
}
function epzEpisode(id) {
  return EPZ_EPISODES.find((e) => e.id === id) || EPZ_EPISODES[0];
}
function epzArticle(id) {
  return EPZ_ARTICLES.find((a) => a.id === id) ||
    (typeof epzHomeShowcase === 'function' ? epzHomeShowcase().find((a) => a.id === id) : null);
}
function epzArticlesOf(episodeId) {
  return EPZ_ARTICLES.filter((a) => a.episode === episodeId);
}
// Lowest price of an article (min variant, or its flat price).
function epzArticleFrom(a) {
  if (a.variants) return Math.min(...a.variants.map((v) => v.price));
  return a.price;
}
// Articles ordered by price, most expensive first (scarf naturally ends up last).
// Used for show priority on the homepage carousel & anywhere we surface products.
function epzArticlesByPrice() {
  return [...EPZ_ARTICLES].sort((a, b) => epzArticleFrom(b) - epzArticleFrom(a));
}
// Display price label: "29 000 ₽" (без «от» — премиум-сегмент).
function epzArticlePriceLabel(a) {
  if (a.variants) return epzFmt(epzArticleFrom(a));
  return epzFmt(a.price);
}
// Photo for an article — its own, else its episode's, else null (placeholder).
function epzArticleImage(a) {
  return a.image || epzEpisode(a.episode).image || null;
}
// Photo for a specific variant (top/bottom/set) of an article — used in cart,
// drawer and checkout so each part of a set shows its OWN photo (as on Новинки),
// never the composite set photo. Falls back to the article's main image.
function epzVariantImage(id, variant) {
  const a = epzArticle(id);
  if (!a) return null;
  if (variant && a.variantImages && a.variantImages[variant]) return a.variantImages[variant];
  return epzArticleImage(a);
}

// ─── Home «Новинки» showcase ─────────────────────────────────────────────────
// Expands articles into the carousel cards the homepage shows: each multi-piece
// look also gets a КОМПЛЕКТ card, and each pyjama splits into верх / низ / комплект.
// These are display-only card objects (carry overrides); they don't change the
// catalog or product data.
function epzHomeShowcase() {
  const A = (id) => EPZ_ARTICLES.find((a) => a.id === id);
  const out = [];

  const dress = A('001-1'), trench = A('001-2');
  // Катрин: платье · тренч · комплект (платье и тренч помечены inBundle — входят в комплект)
  out.push({ ...dress, inBundle: true }, { ...trench, inBundle: true }, {
    ...dress, id: '001-set', wishId: '001-set', isBundle: true,
    homeTitle: 'Комплект Catherine',
    href: '/product/?id=001-set',
    image: '/assets/katrin-set.jpg',
    hoverImage: '/assets/katrin-set.jpg',
    gallery: [{ src: '/assets/katrin-set.jpg', label: 'обтравка', cover: true }],
    sizes: ['S', 'M'],
    bundleParts: [{ id: '001-1' }, { id: '001-2', size: 'ONE SIZE' }],
    short: 'Комплект Catherine', title: 'Комплект Catherine',
    fabric: 'Шерсть и вискоза', price: 55000, priceLabel: epzFmt(55000),
    bundleFull: 61000, bundleSave: 6000,
    bundleItems: [['Платье Catherine', 29000], ['Тренч Catherine', 32000]],
    short: 'Комплект Catherine',
    desc: 'И что вы сделаете? Предъявите мне обвинение в курении?',
    descEn: 'What are you going to do? Charge me with smoking?',
    lead: 'Платье ведёт допрос, а тренч обеспечивает алиби',
    saveNote: 'цена за образ целиком',
    variants: undefined,
  });

  // Эльвира платье
  out.push({ ...A('002-1'), homeTitle: 'Платье Elvira' });

  // Пижамы: верх · низ · комплект (для шёлка и вискозы)
  const splitPyjama = (art, fabricTag, shortNames) => {
    const labels = { top: 'Топ', bottom: 'Брюки', set: 'Комплект' };
    const fullTitles = {
      top: `Топ на тонких бретелях · ${fabricTag}`,
      bottom: `Брюки-палаццо · ${fabricTag}`,
      set: `Комплект: топ и брюки · ${fabricTag}`,
    };
    return art.variants.map((v) => {
      const top = art.variants.find((x) => x.key === 'top').price;
      const bottom = art.variants.find((x) => x.key === 'bottom').price;
      const isSet = v.key === 'set';
      return {
        ...art,
        id: `${art.id}:${v.key}`, wishId: art.id,
        cartId: art.id, cartVariant: v.key,
        href: `/product/?id=${art.id}&variant=${v.key}`,
        image: shortNames[v.key + 'Image'] || art.image,
        hoverImage: shortNames[v.key + 'Hover'] || undefined,
        gallery: shortNames[v.key + 'Gallery'] || undefined,
        title: shortNames[v.key],
        short: isSet ? shortNames.set : shortNames[v.key],
        homeTitle: isSet ? shortNames.setHome : undefined,
        desc: (shortNames[v.key + 'QuoteRu']) || (isSet ? (shortNames.quoteRu || 'Топ и брюки') : art.desc),
        descEn: (shortNames[v.key + 'QuoteEn']) || (isSet ? (shortNames.quoteEn || '') : art.descEn),
        saveNote: isSet ? 'цена за комплект' : undefined,
        price: v.price, priceLabel: epzFmt(v.price),
        // Комплект ведёт себя как Катрин: добавляется как две части (top+bottom) →
        // авто-собирается в комплект в корзине. isBundle → «В корзину» открывает
        // карточку, где выбираются размеры верха и низа отдельно.
        isBundle: isSet || undefined,
        bundleParts: isSet ? [{ id: art.id, variant: 'top' }, { id: art.id, variant: 'bottom' }] : undefined,
        cartVariant: isSet ? undefined : v.key,
        bundleFull: isSet ? top + bottom : undefined,
        bundleSave: isSet ? top + bottom - v.price : undefined,
        bundleItems: isSet ? [['Топ · ' + fabricTag, top], ['Брюки · ' + fabricTag, bottom]] : undefined,
        inBundle: !isSet,
        variants: undefined,
      };
    });
  };
  out.push(...splitPyjama(A('002-2'), 'шёлк', { top: 'Топ Elvira из шёлка', bottom: 'Брюки Elvira из шёлка', set: 'Комплект Elvira из шёлка', setHome: 'Комплект Elvira из шёлка', topImage: '/assets/art-002-silk-top.jpg', bottomImage: '/assets/art-002-silk-pants.jpg', bottomHover: '/assets/art-002-silk-pants-back.jpg', bottomGallery: [{ src: '/assets/art-002-silk-pants.jpg', label: 'обтравка', cover: true }, { src: '/assets/art-002-silk-pants-back.jpg', label: 'спина' }, { src: '/assets/art-002-silk-pants-side.jpg', label: 'бок' }], quoteRu: 'Я дам себе всё, чего я заслуживаю', quoteEn: 'I’m going to give myself everything I deserve', topQuoteRu: 'У меня достаточно друзей. Ещё один мне ни к чему', topQuoteEn: 'I have enough friends. I don’t need another one', bottomQuoteRu: 'Я всегда говорю правду. Даже когда лгу', bottomQuoteEn: 'Me, I always tell the truth. Even when I lie' }));
  out.push(...splitPyjama(A('002-3'), 'вискоза', { top: 'Топ Elvira из вискозы', bottom: 'Брюки Elvira из вискозы', set: 'Комплект Elvira из вискозы', setHome: 'Комплект Elvira из вискозы', topImage: '/assets/art-002-cupro-top-clean.jpg', topHover: '/assets/art-002-cupro-top-back.jpg', bottomImage: '/assets/art-002-cupro-pants.jpg', bottomHover: '/assets/art-002-cupro-pants-back.jpg', quoteRu: 'Тебе нужно гораздо больше, чем я, Тони', quoteEn: 'You need a lot more than me, Tony', topQuoteRu: 'Мир принадлежит тебе', topQuoteEn: 'The World Is Yours', bottomQuoteRu: 'Сначала ты получаешь деньги, потом — власть, а потом — женщину', bottomQuoteEn: 'First you get the money, then you get the power, then you get the woman' }));

  // Хацумомо · Тони
  out.push(A('003-1'), A('004-1'));
  return out;
}

// ─── Small-batch stock + cross-sell ──────────────────────────────────────────
// Mock stock for the urgency mechanics. First batch is tiny (~5 pieces). A stable
// "left" count derived from the id keeps "осталось N из 5" consistent per article.
const EPZ_BATCH_TOTAL = 5;
function epzBatchLeft(id) {
  let h = 0;
  const s = String(id);
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 1000;
  return 1 + (h % 4); // 1..4 left of 5
}

// Bundle pairing: given a cart item (id + variant), suggest the partner piece so
// the look is complete and cheaper as a set. Returns null if no pairing.
function epzCrossSell(id, variant) {
  const card = (cardId) => epzHomeShowcase().find((a) => a.id === cardId);
  // Катрин: платье ↔ тренч (комплект 55 000, выгода 6 000)
  if (id === '001-1') return { card: card('001-2'), addId: '001-2', addVariant: null, addSize: 'ONE SIZE', save: 6000, setHref: '/product/?id=001-set' };
  if (id === '001-2') return { card: card('001-1'), addId: '001-1', addVariant: null, addSize: null, save: 6000, setHref: '/product/?id=001-set' };
  // Пижама шёлк 002-2: топ ↔ брюки (выгода 6 000)
  if (id === '002-2' && variant === 'top') return { card: card('002-2:bottom'), addId: '002-2', addVariant: 'bottom', addSize: null, save: 6000, setHref: '/product/?id=002-2' };
  if (id === '002-2' && variant === 'bottom') return { card: card('002-2:top'), addId: '002-2', addVariant: 'top', addSize: null, save: 6000, setHref: '/product/?id=002-2' };
  // Пижама вискоза 002-3: топ ↔ брюки (выгода 5 000)
  if (id === '002-3' && variant === 'top') return { card: card('002-3:bottom'), addId: '002-3', addVariant: 'bottom', addSize: null, save: 5000, setHref: '/product/?id=002-3' };
  if (id === '002-3' && variant === 'bottom') return { card: card('002-3:top'), addId: '002-3', addVariant: 'top', addSize: null, save: 5000, setHref: '/product/?id=002-3' };
  return null;
}

// ─── Auto-bundle: separate SKUs that form a set get combined automatically ────
// When the cart holds both parts of a look, they're priced as a set (discount
// applied automatically — no user action). Used by the cart drawer, cart page
// and checkout so totals always agree.
const EPZ_BUNDLES = [
  { set: '001-set', title: 'Комплект Catherine', setPrice: 55000, image: '/assets/katrin-set.jpg', parts: [{ id: '001-1', variant: null }, { id: '001-2', variant: null }] },
  { set: '002-2:set', title: 'Комплект Elvira из шёлка', setPrice: 40000, image: '/assets/art-002-pyjama-silk.jpg', parts: [{ id: '002-2', variant: 'top' }, { id: '002-2', variant: 'bottom' }] },
  { set: '002-3:set', title: 'Комплект Elvira из вискозы', setPrice: 35000, image: '/assets/art-002-pyjama-cupro.jpg', parts: [{ id: '002-3', variant: 'top' }, { id: '002-3', variant: 'bottom' }] },
];

function epzLinePrice(c) {
  const a = epzArticle(c.id);
  if (!a) return 0;
  const v = a.variants && c.variant ? a.variants.find((x) => x.key === c.variant) : null;
  return v ? v.price : a.price;
}

// Group a cart into display rows: bundle groups (auto-combined sets) + singles.
// Returns { rows, subtotal, savings }. subtotal already reflects set pricing.
function epzGroupCart(cart) {
  const remaining = cart.map((c, idx) => ({ ...c, idx, qty: c.qty || 1 }));
  const rows = [];
  let savings = 0;

  EPZ_BUNDLES.forEach((b) => {
    const slots = b.parts.map((p) => remaining.find((c) => c.id === p.id && (c.variant || null) === (p.variant || null) && c.qty > 0));
    if (slots.every(Boolean)) {
      const nSets = Math.min(...slots.map((s) => s.qty));
      if (nSets > 0) {
        const fullPer = slots.reduce((s, c) => s + epzLinePrice(c), 0);
        const savePer = fullPer - b.setPrice;
        savings += savePer * nSets;
        rows.push({
          kind: 'bundle', set: b.set, title: b.title, image: b.image || null,
          setPrice: b.setPrice, fullPrice: fullPer, save: savePer, nSets,
          parts: slots.map((c) => ({ line: c, consumed: nSets, a: epzArticle(c.id), price: epzLinePrice(c), img: epzVariantImage(c.id, c.variant) })),
        });
        slots.forEach((s) => { s.qty -= nSets; });
      }
    }
  });

  // leftover singles, in original order
  remaining.filter((c) => c.qty > 0).sort((a, b) => a.idx - b.idx).forEach((c) => {
    rows.push({ kind: 'single', line: c, a: epzArticle(c.id), price: epzLinePrice(c), img: epzVariantImage(c.id, c.variant) });
  });
  // keep bundles roughly where their first part sat
  rows.sort((x, y) => {
    const xi = x.kind === 'bundle' ? Math.min(...x.parts.map((p) => p.line.idx)) : x.line.idx;
    const yi = y.kind === 'bundle' ? Math.min(...y.parts.map((p) => p.line.idx)) : y.line.idx;
    return xi - yi;
  });

  const subtotal = rows.reduce((s, r) => s + (r.kind === 'bundle' ? r.setPrice * r.nSets : r.price * (r.line.qty || 1)), 0);
  return { rows, subtotal, savings };
}

// ─── Backward-compat: EPZ_COLLECTION (episode-level view) ────────────────────
// Older components (homepage hero, lookbook, manifesto) consume an episode list
// with subtitle/price. Derive it from episodes + their articles.
const EPZ_COLLECTION = EPZ_EPISODES.map((ep) => {
  const arts = epzArticlesOf(ep.id);
  const from = Math.min(...arts.map(epzArticleFrom));
  return {
    ...ep,
    subtitle: arts.map((a) => a.title).join(' · '),
    price: epzFmt(from),
    fabric: [...new Set(arts.map((a) => a.fabric))].join(' · '),
    items: arts.length,
    articles: arts,
  };
});

// Subtle striped placeholder. Use for product photography slots.
// Pass tone="dark" for burgundy/dark backgrounds, "light" for cream.
function EpzPlaceholder({ label, ratio = '3/4', tone = 'light', sub, style = {} }) {
  const dark = tone === 'dark';
  const bg = dark ? '#3C1419' : '#D9CEB7';
  const stripe = dark ? 'rgba(255,255,255,0.04)' : 'rgba(20,17,14,0.06)';
  const ink = dark ? 'rgba(239,231,214,0.75)' : 'rgba(20,17,14,0.55)';
  return (
    <div style={{
      aspectRatio: ratio,
      background: `repeating-linear-gradient(135deg, ${bg} 0 12px, ${stripe} 12px 13px)`,
      position: 'relative',
      overflow: 'hidden',
      ...style,
    }}>
      <div style={{
        position: 'absolute',
        inset: 0,
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        gap: 6,
        fontFamily: EPZ_FONTS.mono,
        fontSize: 10,
        letterSpacing: '0.12em',
        color: ink,
        textTransform: 'uppercase',
        textAlign: 'center',
        padding: 20,
      }}>
        <div style={{ opacity: 0.6 }}>[ image ]</div>
        <div style={{ fontWeight: 500 }}>{label}</div>
        {sub && <div style={{ opacity: 0.6, maxWidth: '80%', textTransform: 'none', letterSpacing: 0, fontStyle: 'italic' }}>{sub}</div>}
      </div>
    </div>
  );
}

// "EPIZODE" lockup — used in nav. Optional small "001" pill.
function EpzLockup({ size = 18, color, withMark = false }) {
  const c = color || 'currentColor';
  const ratio = 698.9 / 104.61; // logo aspect
  const h = size * 1.15;
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10, color: c }}>
      <span
        role="img"
        aria-label="THE EPIZODE"
        style={{
          display: 'inline-block',
          height: h,
          width: h * ratio,
          background: c,
          WebkitMaskImage: 'url(/assets/logo-epizode.svg)',
          maskImage: 'url(/assets/logo-epizode.svg)',
          WebkitMaskRepeat: 'no-repeat',
          maskRepeat: 'no-repeat',
          WebkitMaskSize: 'contain',
          maskSize: 'contain',
          WebkitMaskPosition: 'center',
          maskPosition: 'center',
        }}
      />
      {withMark && (
        <div style={{
          fontFamily: EPZ_FONTS.mono,
          fontSize: size * 0.55,
          letterSpacing: '0.15em',
          opacity: 0.7,
          paddingLeft: 10,
          borderLeft: `1px solid ${c}`,
          marginLeft: 2,
        }}>
          COLLECTION&nbsp;001
        </div>
      )}
    </div>
  );
}

// Small perforated ticket-stub element (echoes the brand hangtag).
function EpzTicket({ children, color = EPZ_COLORS.burgundy, fg = EPZ_COLORS.cream, style = {} }) {
  return (
    <div style={{
      position: 'relative',
      background: color,
      color: fg,
      padding: '18px 22px 22px',
      fontFamily: EPZ_FONTS.mono,
      fontSize: 11,
      letterSpacing: '0.14em',
      textTransform: 'uppercase',
      ...style,
    }}>
      <div style={{
        position: 'absolute',
        left: 0, right: 0, top: '62%',
        borderTop: `1.5px dashed ${fg}`,
        opacity: 0.4,
      }} />
      <div style={{
        position: 'absolute',
        top: '62%', left: -8,
        width: 16, height: 16,
        borderRadius: 16,
        background: '#f0eee9',
        transform: 'translateY(-50%)',
      }} />
      <div style={{
        position: 'absolute',
        top: '62%', right: -8,
        width: 16, height: 16,
        borderRadius: 16,
        background: '#f0eee9',
        transform: 'translateY(-50%)',
      }} />
      {children}
    </div>
  );
}

Object.assign(window, {
  EPZ_COLORS, EPZ_FONTS,
  EPZ_EPISODES, EPZ_ARTICLES, EPZ_COLLECTION,
  epzFmt, epzEpisode, epzArticle, epzArticlesOf,
  epzArticleFrom, epzArticlesByPrice, epzArticlePriceLabel, epzArticleImage, epzVariantImage, epzHomeShowcase,
  EPZ_BATCH_TOTAL, epzBatchLeft, epzCrossSell,
  EPZ_BUNDLES, epzGroupCart, epzLinePrice,
  EpzPlaceholder, EpzLockup, EpzTicket,
});
