<script>
  import { onMount, tick } from 'svelte'
  import {
    apiRequest, apiListProjects, apiGetLastProject, apiSaveLastProject, apiCreateProject,
    apiGetProject, apiSaveBlock, apiSaveSettings, apiSaveLetters, apiGetLetter, apiSaveLetter,
    apiDeleteLetter, apiSaveDraft, apiSavePresets, apiSaveNotes, apiGetNote, apiSaveNote,
    apiDeleteNote, apiRenderEmail, apiGetLetterHistory, apiAppendLetterHistory,
    apiGetConfig, apiSaveConfig, apiUploadImage, apiGetSheetsAuthStatus, apiReadSheet, apiWriteSheetCell,
    apiFtpTest, apiFtpUpload, apiFtpList, apiCheckLinks, apiFeedRefresh, apiFeedSuggest, apiAutoAssemble,
    apiSaveStatEntry, apiGetAllStats,
    apiLogin, apiLogout, apiGetMe, apiSavePreferences,
    apiAdminGetUsers, apiAdminCreateUser, apiAdminUpdateUser, apiAdminDeleteUser, apiAdminGetLogs,
  } from './lib/api.js'
  import {
    normalizeNewlines, createId, formatLetterDate, buildLetterName,
    escapeHtml, visualizeLineBreaks, escapeRegExp, unquoteValue,
    normalizeHtmlForCopy, parseOptionCode, parseOptionsObject, stringifyOptions,
    downloadFile,
  } from './lib/utils.js'
  import { parseContentPlan } from './lib/contentPlanParser.js'
  import { mapParsedBlocksToAssembled } from './lib/contentPlanMapper.js'
  import {
    isExtraTextBlock, isListBlock, isProductBlock, trimTrailingEmptyLines, parseBlocks,
    getMixinName, getDefaultMixinFieldLabel, normalizeMixinRules, getMixinRulesForLine,
    getMixinArgCount, isLikelyUiTextValue, guessAutoMixinField, guessObjectFieldByKey,
    shouldExposeObjectField, shouldExposeAutoMixinArg, parseMixinObjectArg, parseQuotedArgs,
    findMixinArgRange, extractMixinArgValue, replaceMixinArgQuoted, replaceMixinArgRaw,
    replaceObjectPropInArg, splitTextField, getParagraphMeta, getListItemRanges,
    getListItemTextLine, buildBaseSchema, parseSections, rebuildContentFromSections,
  } from './lib/parsing.js'
  import { extractTextFromHtml, injectSpellMarks } from './lib/spellcheck.js'
  import CodeEditor from './CodeEditor.svelte'
  import backIcon from './icons/back.svg'
  import clearIcon from './icons/clear.svg'
  import filesIcon from './icons/files.svg'
  import saveIcon from './icons/save.svg'
  import stackIcon from './icons/stack.svg'
  import pugIcon from './icons/pug.svg'
  import plusIconRaw from './icons/plus.svg?raw'
  import settingsIcon from './icons/settings.svg'
  import spacerIcon from './icons/spacer.svg'
  import newMailIcon from './icons/newmail.svg'

  const DEFAULT_SOURCE = '/Block.pug'
  const DEFAULT_MIXIN_RULE_TEMPLATES = [
    {
      test: (name) => /^buttonRounded$/i.test(name),
      fields: [
        { argIndex: 0, type: 'mixin-text', label: 'Текст кнопки' },
        { argIndex: 1, type: 'mixin-href', label: 'Ссылка кнопки' },
      ],
    },
    {
      test: (name) => /^products?/i.test(name),
      fields: [
        { argIndex: 0, type: 'mixin-ids', label: 'ID товаров' },
        { argIndex: 1, type: 'mixin-opts', label: 'Опции' },
      ],
    },
    {
      test: (name) => /^product/i.test(name),
      fields: [
        { argIndex: 0, type: 'mixin-ids', label: 'ID товаров' },
        { argIndex: 1, type: 'mixin-opts', label: 'Опции' },
      ],
    },
  ]

  // --- Auth state ---
  let currentUser = null // { id, login, name, role, projects }
  let authChecked = false
  let loginForm = { login: '', password: '' }
  let loginError = ''

  const LOGIN_BACKGROUNDS = [
    '/login-bg/1.jpg',
    '/login-bg/2.jpg',
    '/login-bg/3.jpg',
    '/login-bg/4.jpg',
    '/login-bg/5.jpg',
  ]
  const loginBg = LOGIN_BACKGROUNDS[Math.floor(Math.random() * LOGIN_BACKGROUNDS.length)]
  let loginLoading = false
  // Admin
  let adminUsers = []
  let adminLoading = false
  let adminNewUser = { login: '', password: '', name: '', role: 'user', projects: [] }
  let adminNewProjectDraft = ''
  let adminEditingId = null
  let adminError = ''
  // Content plan import
  let showImportPlanModal = false
  let importPlanText = ''
  let importPlanParsed = [] // [{subject, preheader, blocks, date?, matched?}]
  let importPlanStep = 'paste' // 'paste' | 'preview' | 'creating'
  let importPlanError = ''
  let importPlanProgress = ''

  // Audit logs
  let auditLogs = []
  let auditLogsTotal = 0
  let auditLogsPage = 1
  let auditLogsLoading = false
  let auditFilterUser = ''
  let auditFilterProject = ''
  let auditFilterAction = ''

  let availableBlocks = []
  let assembledBlocks = []
  let outputPug = ''
  let loadError = ''
  let sourceName = 'Block.pug'
  let projects = []
  let currentProject = 'z51'
  let newProjectName = ''
  let presets = []
  let presetName = ''
  let typografStatus = {}
  let textFieldRefs = {}
  let linkDrafts = {}
  let selectedBlockIndex = ''
  let blockDropdownOpen = false
  let blockDropdownSearch = ''
  let blockDropdownRef = null
  let previewOpen = {}
  let theme = 'light'
  let addMenuFor = null
  let pugOpen = {}
  let activePage = 'builder'
  let outputPanelMode = 'preview'
  let settingsTab = 'project'
  let settings = { globalSpacing: 40, blocks: {}, emailGenProject: '', mixinRules: [], mixinRulesInitialized: false }
  let settingsBlockIndex = 0
  let settingsBlockName = ''
  let settingsTemplate = ''
  let baseSchema = []
  let pugOutputRef
  let blockRanges = {}
  let activeBlockId = null
  let history = []
  let settingsReady = false
  let showPresetInput = false
  let presetSearch = ''
  let presetsCollapsed = true
  let presetSort = 'desc'
  let projectMenuOpen = false
  let productOptionsList = []
  let notes = []
  let currentNoteId = ''
  let noteContent = ''
  let noteSearch = ''
  let showNoteModal = false
  let noteTitleDraft = ''
  let noteError = ''
  let noteTextareaRef
  let sidebarNoteTextareaRef
  let sidebarNoteCollapsed = true
  let assemblyInfo = null
  let assemblyCardHidden = false
  let isDirty = false
  let isSaving = false
  let saveErrorMsg = ''
  let saveTimer = null
  let pendingSave = false
  let blocksScrollRef
  let pendingFocusId = ''
  let showPresetModal = false
  let presetNameDraft = ''
  let presetSourceLetter = null
  let presetError = ''
  let letters = []
  let currentLetterId = ''
  let currentLetterName = ''
  let currentLetterTitle = ''
  let currentLetterDate = ''
  let isEditingLetterName = false
  let letterNameDraft = ''
  let letterNameInput
  let isEditingLetterDate = false
  let letterDateDraft = ''
  let letterDateInput
  let showLetterModal = false
  let letterModalMode = 'new'
  let newLetterTitle = ''
  let newLetterDate = ''
  let newLetterError = ''
  let letterSearch = ''
  let showHistoryFor = null
  let historySnapshots = []
  let historyLoading = false
  let showStartupModal = false
  let startupLastLetter = null
  let startupPlanRows = []
  let previewHtml = ''
  let rawPreviewHtml = ''
  let previewLoading = false
  let previewError = ''
  let previewGeneratedAt = ''
  let previewFlash = false
  let unavailableProducts = []
  let suggestForProduct = null
  let suggestLoading = false
  let suggestResults = []
  let suggestSearch = ''
  let previewSourcePug = ''
  let previewStale = true
  let spellCheckActive = false
  let spellLoading = false
  let spellErrors = []
  let spellerHtml = ''
  let linkCheckActive = false
  let linkCheckLoading = false
  let linkCheckResults = []
  let autoAssembleOpen = false
  let autoAssembleType = 'new'
  let autoAssembleLayout = '3 3 3'
  let autoAssembleProductType = 'coin'
  let autoAssemblePricePreset = 'any'
  let autoAssemblePriceMin = ''
  let autoAssemblePriceMax = ''
  let autoAssembleExcludeGold = false
  let autoAssembleExcludeSilver = false
  let autoAssembleExcludePlatinum = false
  let autoAssembleLoading = false
  let autoAssembleError = ''
  let autoAssembleBlocks = []
  let previewIframeRef
  let autoRenderTimer = null
  let quickEditBlock = null
  let quickEditField = null
  let quickEditValue = ''
  let quickEditPos = { top: 0, left: 0 }
  let quickEditVisible = false
  let customBlockName = ''
  let customBlockContent = ''
  let customBlockError = ''
  let editingCustomBlockId = null
  let templatesExpanded = new Set()
  let templateSearch = ''
  const PREVIEW_TARGET_WIDTH = 600
  let previewContentWidth = 600
  let previewContentHeight = 900
  let previewScale = 1
  let previewScaledWidth = 600
  let previewScaledHeight = 900

  // --- Google Sheets / Plan page ---
  let sheetsConfig = {}, sheetsAuthStatus = { configured: false, authorized: false }
  let sheetsClientIdDraft = '', sheetsClientSecretDraft = '', sheetsSavingConfig = false, sheetsConfigError = ''
  let sheetsCfgSpreadsheetId = '', sheetsCfgSheetName = ''
  let sheetsCfgDateCol = 'A', sheetsCfgSubjectCol = 'D', sheetsCfgPreheaderCol = 'E', sheetsCfgStatusCol = 'F'
  let sheetsCfgStatusFilter = '', sheetsCfgHeaderRows = 1
  let sheetsCfgExtraCols = []
  let sheetsTestResult = '', sheetsTestLoading = false
  // --- Image upload ---
  let uploadBaseUrl = '', uploadSaving = false, uploadConfigError = '', uploadConfigStatus = ''
  let uploadingFields = {}
  let dragOverField = null
  // --- FTP/SFTP upload ---
  let ftpProtocol = 'ftp', ftpHost = '', ftpPort = '', ftpUser = '', ftpPassword = ''
  let ftpRemotePath = '', ftpBaseUrl = ''
  let mailingServiceLabel = '', mailingServiceUrl = ''
  let feedUrlDraft = ''
  let retailcrmUrlDraft = '', retailcrmApiKeyDraft = ''
  // TZ import modal
  let tzModalBlockId = null, tzModalText = '', tzModalLoading = false, tzModalResults = [], tzModalError = ''
  let ftpTestResult = '', ftpTestLoading = false
  let showFtpGalleryModal = false, ftpGalleryImages = [], ftpGalleryLoading = false, ftpGalleryError = ''
  let ftpGalleryFolder = '', ftpUploadFileName = '', ftpUploadFile = null, ftpUploading = false, ftpUploadError = ''
  let ftpGalleryTargetField = null
  // --- Plan page ---
  let planRows = [], planLoading = false, planError = '', planLoadedAt = null, planWarnings = []
  let planPollingTimer = null, planNotificationsEnabled = false
  let toasts = []
  let toastId = 0
  let planEditingRowId = null, planSavingRowId = null, planSaveError = ''
  $: planStatusOptions = [...new Set(planRows.map(r => r.status).filter(Boolean))].sort()
  let planCalMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1)
  let planCalSelectedDate = ''
  let planShowDone = false
  const planTodayIso = planDateToIso(new Date())

  $: planDateMap = (() => {
    const map = new Map()
    for (const row of planRows) {
      const key = planDateToIso(row.date)
      if (!map.has(key)) map.set(key, [])
      map.get(key).push(row)
    }
    return map
  })()

  $: planCalDays = (() => {
    const year = planCalMonth.getFullYear()
    const month = planCalMonth.getMonth()
    const firstDay = new Date(year, month, 1)
    const lastDay = new Date(year, month + 1, 0)
    const startDow = (firstDay.getDay() + 6) % 7
    const days = []
    for (let i = startDow - 1; i >= 0; i--) {
      const d = new Date(year, month, -i)
      const iso = planDateToIso(d)
      days.push({ date: d, current: false, isoStr: iso, rows: planDateMap.get(iso) || [] })
    }
    for (let n = 1; n <= lastDay.getDate(); n++) {
      const d = new Date(year, month, n)
      const iso = planDateToIso(d)
      days.push({ date: d, current: true, isoStr: iso, rows: planDateMap.get(iso) || [] })
    }
    while (days.length < 42) {
      const prev = days[days.length - 1].date
      const d = new Date(prev.getFullYear(), prev.getMonth(), prev.getDate() + 1)
      const iso = planDateToIso(d)
      days.push({ date: d, current: false, isoStr: iso, rows: planDateMap.get(iso) || [] })
    }
    return days
  })()

  function prevPlanMonth() {
    planCalMonth = new Date(planCalMonth.getFullYear(), planCalMonth.getMonth() - 1, 1)
  }
  function nextPlanMonth() {
    planCalMonth = new Date(planCalMonth.getFullYear(), planCalMonth.getMonth() + 1, 1)
  }
  async function selectPlanDate(iso) {
    planCalSelectedDate = planCalSelectedDate === iso ? '' : iso
    if (planCalSelectedDate) {
      await tick()
      document.getElementById(`plan-day-${planCalSelectedDate}`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
    }
  }

  $: planDayGroups = (() => {
    const rows = planShowDone ? planRows : planRows.filter(r => planStatusClass(r.status) !== 'done')
    const map = new Map()
    for (const row of rows) {
      const key = planDateToIso(row.date)
      if (!map.has(key)) map.set(key, { date: row.date, isoStr: key, rows: [] })
      map.get(key).rows.push(row)
    }
    return [...map.values()]
  })()

  $: planPeriodGroups = (() => {
    const now = new Date()
    const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
    const tomorrow = new Date(today.getTime() + 86400000)
    const dayOfWeek = today.getDay() === 0 ? 7 : today.getDay()
    const endOfWeek = new Date(today.getTime() + (7 - dayOfWeek) * 86400000)
    const endOfNextWeek = new Date(endOfWeek.getTime() + 7 * 86400000)
    const periods = [
      { key: 'overdue', label: 'Просроченные', accent: 'error', groups: [] },
      { key: 'today',    label: 'Сегодня',              accent: 'urgent', groups: [] },
      { key: 'tomorrow', label: 'Завтра',                accent: 'warn',   groups: [] },
      { key: 'week',     label: 'На этой неделе',        accent: '',       groups: [] },
      { key: 'next',     label: 'Следующая неделя',      accent: '',       groups: [] },
      { key: 'later',    label: 'Позже',                 accent: '',       groups: [] },
    ]
    for (const dg of planDayGroups) {
      const d = dg.date
      if (d < today)         periods[0].groups.push(dg)
      else if (d.getTime() === today.getTime())    periods[1].groups.push(dg)
      else if (d.getTime() === tomorrow.getTime()) periods[2].groups.push(dg)
      else if (d <= endOfWeek)     periods[3].groups.push(dg)
      else if (d <= endOfNextWeek) periods[4].groups.push(dg)
      else                         periods[5].groups.push(dg)
    }
    return periods.filter(p => p.groups.length > 0)
  })()

  $: acardPlanRows = (() => {
    if (planRows.length === 0) return []
    return planRows.filter(r => r.project === currentProject && r.date)
  })()

  let assemblyAutoMatched = false
  $: if (!assemblyInfo && !assemblyAutoMatched && currentLetterTitle && currentLetterDate && planRows.length > 0) {
    assemblyAutoMatched = true
    const match = planRows.find(r => r.project === currentProject && r.subject === currentLetterTitle && planDateToIso(r.date) === currentLetterDate)
    if (match) {
      assemblyInfo = { subject: match.subject, preheader: match.preheader, status: match.status, dateStr: match.dateStr, project: match.project, extra: match.extra || [] }
      assemblyCardHidden = false
    }
  }

  let acardCalCollapsed = false
  let acardListCollapsed = true
  let acardCalMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1)

  function acardCalDays(month, rows) {
    const year = month.getFullYear(), mo = month.getMonth()
    const firstDay = new Date(year, mo, 1)
    let startDow = firstDay.getDay(); if (startDow === 0) startDow = 7
    const daysInMonth = new Date(year, mo + 1, 0).getDate()
    const today = new Date(); today.setHours(0,0,0,0)
    const cells = []
    for (let i = 1; i < startDow; i++) cells.push(null)
    for (let d = 1; d <= daysInMonth; d++) {
      const date = new Date(year, mo, d)
      const iso = planDateToIso(date)
      const dayRows = rows.filter(r => planDateToIso(r.date) === iso)
      const isToday = date.getTime() === today.getTime()
      const isPast = date < today
      cells.push({ d, date, iso, rows: dayRows, isToday, isPast })
    }
    return cells
  }

  let acardCalInitialized = false
  $: if (acardPlanRows.length > 0 && !acardCalInitialized) {
    acardCalInitialized = true
    const nearest = acardPlanRows.reduce((best, r) => {
      const diff = Math.abs(r.date.getTime() - Date.now())
      return diff < best.diff ? { diff, date: r.date } : best
    }, { diff: Infinity, date: null })
    if (nearest.date) {
      acardCalMonth = new Date(nearest.date.getFullYear(), nearest.date.getMonth(), 1)
    }
  }
  $: acardCalCells = acardCalDays(acardCalMonth, acardPlanRows)

  function acardCalPrev() { acardCalMonth = new Date(acardCalMonth.getFullYear(), acardCalMonth.getMonth() - 1, 1) }
  function acardCalNext() { acardCalMonth = new Date(acardCalMonth.getFullYear(), acardCalMonth.getMonth() + 1, 1) }

  function planStatusClass(status) {
    const s = (status || '').toLowerCase()
    if (s.includes('отправлен') || s.includes('готов') || s.includes('выслан')) return 'done'
    if (s.includes('сборк') || s.includes('поставлен') || s.includes('очередь') || s.includes('отправк')) return 'queue'
    if (s.includes('рисов') || s.includes('визуал') || s.includes('разработ') || s.includes('передан')) return 'wip'
    return ''
  }

  const plusIconSvg = plusIconRaw.replace(/stroke="[^"]*"/g, 'stroke="currentColor"')

  // --- Stats / time tracking ---
  let statsTracking = null // { letterId, letterName, project, tag, startedAt, activeSeconds, htmlCopied, interval }
  let allStats = {} // { project: [entries] } for stats page
  let statsPageLoading = false

  function statsStart(letterId, letterName, project, tag) {
    statsStop()
    const t = {
      letterId, letterName, project, tag,
      startedAt: new Date().toISOString(),
      activeSeconds: 0,
      htmlCopied: false,
      interval: null,
      lastTick: Date.now(),
    }
    t.interval = setInterval(() => {
      if (t.htmlCopied || document.visibilityState === 'visible') {
        const now = Date.now()
        const elapsed = Math.min((now - t.lastTick) / 1000, 5)
        t.activeSeconds += elapsed
        t.lastTick = now
      } else {
        t.lastTick = Date.now()
      }
    }, 1000)
    statsTracking = t
  }

  function statsMarkHtmlCopied() {
    if (statsTracking) statsTracking.htmlCopied = true
  }

  async function statsStop(finalStatus) {
    const t = statsTracking
    if (!t) return
    clearInterval(t.interval)
    statsTracking = null
    const minutes = Math.round(t.activeSeconds / 60)
    if (minutes < 1 && !finalStatus) return
    try {
      await apiSaveStatEntry(t.project, {
        letterId: t.letterId,
        letterName: t.letterName,
        tag: t.tag || '',
        startedAt: t.startedAt,
        completedAt: finalStatus ? new Date().toISOString() : '',
        minutes: Math.max(1, minutes),
        status: finalStatus || '',
      })
    } catch {}
  }

  $: statsMonthGroups = (() => {
    const monthMap = new Map()
    for (const [proj, entries] of Object.entries(allStats)) {
      for (const e of entries) {
        const d = new Date(e.startedAt || e.completedAt)
        const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
        if (!monthMap.has(key)) monthMap.set(key, [])
        monthMap.get(key).push({ ...e, project: proj })
      }
    }
    return [...monthMap.entries()].sort((a, b) => b[0].localeCompare(a[0]))
  })()

  async function loadAllStats() {
    statsPageLoading = true
    try {
      const data = await apiGetAllStats()
      allStats = data.stats || {}
    } catch { allStats = {} }
    statsPageLoading = false
  }

  async function handleLogin() {
    loginError = ''
    loginLoading = true
    try {
      const data = await apiLogin(loginForm.login, loginForm.password)
      currentUser = data.user
      loginForm = { login: '', password: '' }
      await initProjects()
      await loadProject(currentProject)
      await loadSheetsAuthStatus()
      settingsReady = true
    } catch (e) {
      loginError = e?.message === 'invalid_credentials' ? 'Неверный логин или пароль' : (e?.message || 'Ошибка входа')
    }
    loginLoading = false
  }

  async function handleLogout() {
    await apiLogout()
    currentUser = null
    authChecked = true
  }

  // --- Content plan import ---
  function parseImportPlan() {
    importPlanError = ''
    try {
      const parsed = parseContentPlan(importPlanText)
      if (!parsed.length) { importPlanError = 'Не найдено ни одного письма. Проверьте формат (должно начинаться с "Письмо 1.")'; return }
      // Match subjects to plan rows for dates
      importPlanParsed = parsed.map(letter => {
        const match = planRows.find(r =>
          r.project === currentProject &&
          r.subject?.trim().toLowerCase() === letter.subject?.trim().toLowerCase()
        )
        return {
          ...letter,
          date: match ? planDateToIso(match.date) : new Date().toISOString().slice(0, 10),
          matched: !!match,
          selected: true,
        }
      })
      importPlanStep = 'preview'
    } catch (e) {
      importPlanError = e.message || 'Ошибка парсинга'
    }
  }

  function planDateToIso(dateStr) {
    if (!dateStr) return new Date().toISOString().slice(0, 10)
    if (dateStr instanceof Date) return dateStr.toISOString().slice(0, 10)
    const s = String(dateStr)
    // Try DD/MM/YYYY or DD.MM.YYYY
    const m = s.match(/(\d{1,2})[/.](\d{1,2})[/.](\d{4})/)
    if (m) return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`
    return s
  }

  async function executeImportPlan() {
    importPlanStep = 'creating'
    importPlanError = ''
    const selected = importPlanParsed.filter(l => l.selected)
    const allBlocks = parseBlocks(blockText)
    let created = 0

    for (const letter of selected) {
      importPlanProgress = `Создаю ${created + 1}/${selected.length}: ${letter.subject}`
      try {
        const id = createId()
        const date = letter.date
        const title = letter.subject
        const name = buildLetterName(title, date)
        const now = new Date().toISOString()
        const blocks = mapParsedBlocksToAssembled(letter.blocks, allBlocks, settings)

        // Add to letters list
        const nextLetter = { id, name, title, date, createdAt: now, updatedAt: now }
        letters = [nextLetter, ...letters]

        // Save letter with blocks and metadata
        await apiSaveLetters(currentProject, { list: letters, currentId: letters[0]?.id || '' })
        await apiSaveLetter(currentProject, {
          ...nextLetter,
          blocks,
          assemblyInfo: {
            subject: letter.subject,
            preheader: letter.preheader,
            project: currentProject,
            dateStr: date.split('-').reverse().join('/'),
          },
        })
        created++
      } catch (e) {
        importPlanError += `Ошибка: ${letter.subject} — ${e.message}\n`
      }
    }
    importPlanProgress = `Создано ${created} из ${selected.length} писем`
    importPlanStep = 'paste'
    if (created > 0) {
      // Reload project to refresh letters
      const data = await apiGetProject(currentProject)
      letters = data.letters?.list || []
    }
  }

  async function loadAuditLogs() {
    auditLogsLoading = true
    try {
      const data = await apiAdminGetLogs({
        user: auditFilterUser, project: auditFilterProject, action: auditFilterAction,
        page: auditLogsPage, limit: 50,
      })
      auditLogs = data.entries || []
      auditLogsTotal = data.total || 0
    } catch { auditLogs = [] }
    auditLogsLoading = false
  }

  async function loadAdminUsers() {
    adminLoading = true
    try {
      const data = await apiAdminGetUsers()
      adminUsers = data.users || []
    } catch { adminUsers = [] }
    adminLoading = false
  }

  async function adminCreateUser() {
    adminError = ''
    if (!adminNewUser.login || !adminNewUser.password) { adminError = 'Логин и пароль обязательны'; return }
    try {
      await apiAdminCreateUser(adminNewUser)
      adminNewUser = { login: '', password: '', name: '', role: 'user', projects: [] }
      await loadAdminUsers()
    } catch (e) { adminError = e?.message || 'Ошибка' }
  }

  async function adminUpdateUser(id, data) {
    adminError = ''
    try {
      await apiAdminUpdateUser(id, data)
      await loadAdminUsers()
    } catch (e) { adminError = e?.message || 'Ошибка' }
  }

  async function adminDeleteUser(id) {
    if (!confirm('Удалить пользователя?')) return
    try {
      await apiAdminDeleteUser(id)
      await loadAdminUsers()
    } catch (e) { adminError = e?.message || 'Ошибка' }
  }

  $: setBodyTheme(theme)

  onMount(async () => {
    const meData = await apiGetMe()
    if (meData?.user) {
      currentUser = meData.user
    }
    authChecked = true
    if (!currentUser) return
    await initProjects()
    await loadProject(currentProject)
    await loadSheetsAuthStatus()
    settingsReady = true
    const savedPage = localStorage.getItem('z51-active-page')
    if (['builder', 'settings', 'presets', 'notes', 'plan', 'stats', 'admin'].includes(savedPage)) {
      setActivePage(savedPage)
    }
    const userTheme = currentUser?.theme
    const savedTheme = localStorage.getItem('z51-theme')
    if (userTheme === 'dark' || userTheme === 'light') {
      theme = userTheme
    } else if (savedTheme === 'dark' || savedTheme === 'light') {
      theme = savedTheme
    }
    enablePlanNotifications()
    const planCache = loadPlanCache()
    if (planCache && planCache.rows.length > 0) {
      planRows = planCache.rows
      planLoadedAt = planCache.at
    }
    loadPlan(true)
    startPlanPolling()
    applyAutoTitles()
    const mutationObserver = new MutationObserver(() => applyAutoTitles())
    mutationObserver.observe(document.body, { childList: true, subtree: true })
    const onResize = () => fitPreviewFrame()
    window.addEventListener('resize', onResize)
    const onMessage = (e) => {
      if (e.data?.type === 'z51-preview-click') handlePreviewElementClick(e.data)
    }
    window.addEventListener('message', onMessage)
    const onStorage = (e) => {
      if (e.key === 'z51-oauth-result') {
        if (JSON.parse(e.newValue || '{}').ok) loadSheetsAuthStatus()
        localStorage.removeItem('z51-oauth-result')
      }
    }
    window.addEventListener('storage', onStorage)
    document.addEventListener('click', closeBlockDropdown)
    return () => {
      mutationObserver.disconnect()
      window.removeEventListener('resize', onResize)
      window.removeEventListener('message', onMessage)
      window.removeEventListener('storage', onStorage)
      document.removeEventListener('click', closeBlockDropdown)
    }
  })

  $: settingsBlockName = allBlocks[settingsBlockIndex]?.name ?? ''
  $: settingsTemplate = settingsBlockName ? getEffectiveTemplate(settingsBlockName) : ''
  $: baseSchema = buildBaseSchema(settingsTemplate, settingsBlockName, settings?.mixinRules)
  $: settings?.accentColor && setAccentColor(settings.accentColor)
  $: customBlocksList = (settings?.customBlocks || []).map(cb => ({
    name: cb.name, content: cb.content, isCustom: true, customId: cb.id,
  }))
  $: allBlocks = (() => {
    const seen = new Set()
    const result = []
    for (const b of [...availableBlocks, ...customBlocksList]) {
      if (!seen.has(b.name)) { seen.add(b.name); result.push(b) }
    }
    return result
  })()
  $: filteredTemplateBlocks = templateSearch.trim()
    ? allBlocks.filter(b => b.name.toLowerCase().includes(templateSearch.toLowerCase()))
    : allBlocks
  $: allSpacingEnabled = allBlocks.every(b => (settings?.blocks?.[b.name]?.spacingEnabled ?? true))
  $: quickBlocksView = (() => {
    const raw = getQuickBlocks(settings)
    if (allBlocks.length === 0) return raw
    const allowed = new Set(allBlocks.map(b => b.name))
    return raw.filter(n => allowed.has(n))
  })()
  $: productOptionsList = (settings?.productOptions || [])
    .map((opt) => ({
      label: opt.label || opt.code,
      code: opt.code || '',
    }))
    .filter((opt) => opt.code)
  $: filteredLetters = letterSearch.trim()
    ? letters.filter((l) => l.name.toLowerCase().includes(letterSearch.toLowerCase()))
    : letters
  $: previewStale = previewSourcePug !== outputPug
  $: previewScaledHeight = Math.max(420, Math.round(previewContentHeight * previewScale))
  $: if (!previewHtml) {
    previewScale = 1
    previewContentWidth = PREVIEW_TARGET_WIDTH
    previewContentHeight = 900
    previewScaledWidth = PREVIEW_TARGET_WIDTH
  }

  async function loadFromUrl(url, label) {
    loadError = ''
    try {
      const res = await fetch(url)
      if (!res.ok) throw new Error(`Не удалось загрузить ${label}`)
      const text = await res.text()
      sourceName = label
      availableBlocks = parseBlocks(text)
      maybeAutoInitMixinRules()
      await apiSaveBlock(currentProject, { sourceName: label, blockText: text })
      assembledBlocks = []
      rebuildOutput()
    } catch (err) {
      loadError = err.message
    }
  }

  async function handleFile(event) {
    const file = event.target.files?.[0]
    if (!file) return
    const text = await file.text()
    sourceName = file.name
    availableBlocks = parseBlocks(text)
    maybeAutoInitMixinRules()
    await apiSaveBlock(currentProject, { sourceName: file.name, blockText: text })
    assembledBlocks = []
    rebuildOutput()
  }

  async function initProjects() {
    try {
      projects = await apiListProjects()
    } catch {
      projects = []
    }
    if (!projects.length) {
      await apiCreateProject('z51')
      projects = await apiListProjects()
    }
    let lastProject = ''
    try {
      lastProject = await apiGetLastProject()
    } catch {
      lastProject = ''
    }
    const storedProject =
      typeof localStorage !== 'undefined' ? localStorage.getItem('z51-last-project') || '' : ''
    const preferred = projects.includes(storedProject)
      ? storedProject
      : projects.includes(lastProject)
        ? lastProject
        : ''
    currentProject = preferred || projects[0] || 'z51'
  }

  async function loadProject(name, forcePickLetter = false) {
    statsStop()
    currentProject = name
    assemblyInfo = null
    assemblyCardHidden = false
    acardCalInitialized = false
    apiSaveLastProject(name).catch(() => {})
    if (typeof localStorage !== 'undefined') {
      localStorage.setItem('z51-last-project', name)
    }
    const data = await apiGetProject(name)
    sourceName = data.meta?.sourceName || sourceName
    availableBlocks = parseBlocks(data.blockText || '')
    settings = {
      globalSpacing: 40,
      blocks: {},
      accentColor: '#6b7280',
      projectTitle: '',
      emailGenProject: '',
      productOptions: [],
      mixinRules: [],
      mixinRulesInitialized: false,
      sidebarNote: '',
      ...(data.settings || {}),
    }
    const hadQuickBlocks = Array.isArray(settings.quickBlocks)
    const hasMixinRules =
      data.settings && Object.prototype.hasOwnProperty.call(data.settings, 'mixinRules')
    let normalizedSettings = ensureQuickBlocks(settings)
    if (hasMixinRules) {
      normalizedSettings = {
        ...normalizedSettings,
        mixinRules: normalizeMixinRules(normalizedSettings.mixinRules),
        mixinRulesInitialized:
          data.settings && Object.prototype.hasOwnProperty.call(data.settings, 'mixinRulesInitialized')
            ? Boolean(normalizedSettings.mixinRulesInitialized)
            : true,
      }
    } else {
      const defaultMixinRules = buildDefaultMixinRulesFromBlocks(allBlocks)
      normalizedSettings = {
        ...normalizedSettings,
        mixinRules: defaultMixinRules,
        mixinRulesInitialized: defaultMixinRules.length > 0,
      }
    }
    settings = normalizedSettings
    const _sc = normalizedSettings.sheetsConfig || {}
    sheetsCfgSpreadsheetId = _sc.spreadsheetId || ''; sheetsCfgSheetName = _sc.sheetName || ''
    sheetsCfgDateCol = _sc.columns?.date || 'A'; sheetsCfgSubjectCol = _sc.columns?.subject || 'D'
    sheetsCfgPreheaderCol = _sc.columns?.preheader || 'E'; sheetsCfgStatusCol = _sc.columns?.status || 'F'
    sheetsCfgStatusFilter = (_sc.statusFilter || []).join(', ')
    sheetsCfgHeaderRows = _sc.headerRows ?? 1
    sheetsCfgExtraCols = (_sc.extraColumns || []).map(c => ({ ...c }))
    const _ftp = normalizedSettings.ftpConfig || {}
    ftpProtocol = _ftp.protocol || 'ftp'; ftpHost = _ftp.host || ''; ftpPort = _ftp.port || ''
    ftpUser = _ftp.user || ''; ftpPassword = ''; ftpRemotePath = _ftp.remotePath || ''; ftpBaseUrl = _ftp.baseUrl || ''
    const _ms = normalizedSettings.mailingService || {}
    mailingServiceLabel = _ms.label || ''; mailingServiceUrl = _ms.url || ''
    feedUrlDraft = normalizedSettings.feedUrl || ''
    const _rc = normalizedSettings.retailcrmConfig || {}
    retailcrmUrlDraft = _rc.url || ''; retailcrmApiKeyDraft = _rc.apiKey || ''
    if (!hadQuickBlocks || (!hasMixinRules && normalizedSettings.mixinRulesInitialized)) {
      saveSettings(normalizedSettings)
    }
    quickBlocksView = hadQuickBlocks
      ? normalizedSettings.quickBlocks
      : normalizeQuickBlocks(defaultQuickBlocks)
    presets = data.presets || []
    letters = Array.isArray(data.letters?.list) ? data.letters.list : []
    currentLetterId = data.letters?.currentId || ''
    currentLetterName = ''
    currentLetterTitle = ''
    currentLetterDate = ''
    quickBlockCandidate = ''
    notes = Array.isArray(data.notes?.list) ? data.notes.list : []
    currentNoteId = data.notes?.currentId || ''
    noteContent = ''
    // Start both fetches in parallel
    const notePromise = currentNoteId ? apiGetNote(name, currentNoteId).catch(() => null) : null
    const normalizedDraft = normalizeDraftBlocks(data.draft || [])
    let normalizedBlocks = normalizedDraft
    if (forcePickLetter) {
      startupLastLetter = currentLetterId ? letters.find(l => l.id === currentLetterId) || null : null
      const filterProject = (rows) => rows.filter(r => r.project === name)
      startupPlanRows = filterProject(planRows)
      currentLetterId = ''
      showStartupModal = true
      if (startupPlanRows.length === 0) {
        fetchPlanRows().then(({ rows }) => {
          planRows = rows
          planLoadedAt = new Date()
          savePlanCache(rows, planLoadedAt)
          startupPlanRows = filterProject(rows)
        }).catch(() => {})
      }
    } else if (currentLetterId) {
      const letterData = await apiGetLetter(name, currentLetterId).catch(() => null)
      const letter = letterData?.letter
      if (letter && Array.isArray(letter.blocks)) {
        normalizedBlocks = normalizeDraftBlocks(letter.blocks || [])
        currentLetterName = letter.name || ''
        currentLetterTitle = letter.title || ''
        currentLetterDate = letter.date || ''
        assemblyInfo = letter.assemblyInfo || null
      }
      if (!currentLetterName) {
        const meta = letters.find((item) => item.id === currentLetterId)
        if (meta) {
          currentLetterName = meta.name || ''
          currentLetterTitle = meta.title || ''
          currentLetterDate = meta.date || ''
        }
      }
    } else {
      showStartupModal = true
    }
    assembledBlocks = mapDraftBlocks(normalizedBlocks)
    rebuildOutput()
    previewHtml = ''
    previewError = ''
    previewGeneratedAt = ''
    previewSourcePug = ''
    outputPanelMode = 'preview'
    isDirty = false
    selectedBlockIndex = ''
    settingsBlockIndex = 0
    if (currentNoteId) {
      const noteData = await notePromise
      const note = noteData?.note
      if (note) {
        noteContent = note.content || ''
        await tick()
        autoResizeTextarea(noteTextareaRef)
      }
    await tick()
    autoResizeTextarea(sidebarNoteTextareaRef)
  }
  }

  async function addProject() {
    const name = newProjectName.trim()
    if (!name) return
    if (projects.includes(name)) {
      newProjectName = ''
      await loadProject(name)
      return
    }
    try {
      await apiCreateProject(name)
      projects = await apiListProjects()
      newProjectName = ''
      await loadProject(name)
    } catch (err) {
      loadError = 'Не удалось создать проект'
    }
  }

  async function selectProjectByName(name) {
    if (!name) return
    projectMenuOpen = false
    activePage = 'builder'
    await loadProject(name, true)
  }

  function mapDraftBlocks(list) {
    return list.map((block) => ({
      id: createId(),
      name: block.name,
      content: block.content,
      collapsed: block.collapsed ?? true,
      // Always rebuild schema from current content to avoid stale field sets from old saved drafts.
      schema: buildBaseSchema(block.content, block.name, settings?.mixinRules),
      addSpacing: block.addSpacing ?? true,
      spacing: typeof block.spacing === 'number' ? block.spacing : undefined,
    }))
  }

  async function openLetter(letter) {
    if (!letter?.id) return
    assemblyAutoMatched = false
    currentLetterId = letter.id
    currentLetterName = letter.name || ''
    currentLetterTitle = letter.title || ''
    currentLetterDate = letter.date || ''
    await apiSaveLetters(currentProject, { list: letters, currentId: currentLetterId })
    try {
      const data = await apiGetLetter(currentProject, letter.id)
      const l = data?.letter
      const normalized = normalizeDraftBlocks(l?.blocks || [])
      assembledBlocks = mapDraftBlocks(normalized)
      assemblyInfo = l?.assemblyInfo || null
      if (!assemblyInfo && letter.title && letter.date) {
        const row = planRows.find(r => r.project === currentProject && r.subject === letter.title && planDateToIso(r.date) === letter.date)
        if (row) {
          assemblyInfo = { subject: row.subject, preheader: row.preheader, status: row.status, dateStr: row.dateStr, project: row.project, extra: row.extra || [] }
        }
      }
      assemblyCardHidden = false
    } catch {
      assembledBlocks = []
    }
    const tag = (assemblyInfo?.extra || []).find(e => !e.link && !isUrl(e.value))?.value || ''
    statsStart(letter.id, letter.name || letter.title || '', currentProject, tag)
    rebuildOutput()
    setActivePage('builder')
  }

  async function deleteLetter(letter) {
    if (!letter?.id) return
    const confirmed = typeof window === 'undefined' || window.confirm('Удалить письмо?')
    if (!confirmed) return
    await apiDeleteLetter(currentProject, letter.id)
    letters = letters.filter((item) => item.id !== letter.id)
    if (currentLetterId === letter.id) {
      currentLetterId = ''
      currentLetterName = ''
      currentLetterTitle = ''
      currentLetterDate = ''
      assembledBlocks = []
      rebuildOutput()
    }
    await apiSaveLetters(currentProject, { list: letters, currentId: currentLetterId })
  }

  async function duplicateLetter(letter) {
    if (!letter?.id) return
    let blocks = []
    try {
      const data = await apiGetLetter(currentProject, letter.id)
      blocks = normalizeDraftBlocks(data?.letter?.blocks || [])
    } catch {}
    const id = createId()
    const title = 'Копия — ' + (letter.title || letter.name)
    const date = letter.date || new Date().toISOString().slice(0, 10)
    const name = buildLetterName(title, date)
    const now = new Date().toISOString()
    const nextLetter = { id, name, title, date, createdAt: now, updatedAt: now }
    letters = [nextLetter, ...letters]
    currentLetterId = id
    currentLetterName = name
    currentLetterTitle = title
    currentLetterDate = date
    assembledBlocks = mapDraftBlocks(blocks)
    rebuildOutput()
    await apiSaveLetters(currentProject, { list: letters, currentId: id })
    await apiSaveLetter(currentProject, { ...nextLetter, blocks: buildDraftPayload() })
    setActivePage('builder')
  }

  async function openHistory(letter) {
    if (showHistoryFor?.id === letter.id) {
      showHistoryFor = null
      return
    }
    showHistoryFor = letter
    historyLoading = true
    historySnapshots = []
    try {
      const data = await apiGetLetterHistory(currentProject, letter.id)
      historySnapshots = data?.history || []
    } catch {}
    historyLoading = false
  }

  async function restoreFromHistory(snapshot) {
    if (!showHistoryFor) return
    const letter = showHistoryFor
    showHistoryFor = null
    currentLetterId = letter.id
    currentLetterName = letter.name || ''
    currentLetterTitle = letter.title || ''
    currentLetterDate = letter.date || ''
    assembledBlocks = mapDraftBlocks(normalizeDraftBlocks(snapshot.blocks || []))
    rebuildOutput()
    setActivePage('builder')
  }

  function openNoteModal() {
    noteTitleDraft = ''
    noteError = ''
    showNoteModal = true
  }

  async function createNote() {
    const title = noteTitleDraft.trim()
    if (!title) {
      noteError = 'Укажи название заметки.'
      return
    }
    const now = new Date().toISOString()
    const id = createId()
    const newNote = { id, title, createdAt: now, updatedAt: now }
    notes = [newNote, ...notes]
    currentNoteId = id
    noteContent = ''
    showNoteModal = false
    await apiSaveNotes(currentProject, { list: notes, currentId: currentNoteId })
    await apiSaveNote(currentProject, { ...newNote, content: '' })
    setActivePage('notes')
  }

  async function openNote(note) {
    if (!note?.id) return
    if (currentNoteId === note.id) {
      currentNoteId = ''
      noteContent = ''
      await apiSaveNotes(currentProject, { list: notes, currentId: currentNoteId })
      return
    }
    currentNoteId = note.id
    noteContent = ''
    await apiSaveNotes(currentProject, { list: notes, currentId: currentNoteId })
    try {
      const data = await apiGetNote(currentProject, note.id)
      const loaded = data?.note
      noteContent = loaded?.content || ''
      await tick()
      autoResizeTextarea(noteTextareaRef)
    } catch {
      noteContent = ''
    }
  }

  async function saveNote() {
    if (!currentNoteId) return
    const now = new Date().toISOString()
    const existingIndex = notes.findIndex((item) => item.id === currentNoteId)
    const existingTitle = existingIndex !== -1 ? notes[existingIndex].title : ''
    const nextNote = {
      id: currentNoteId,
      title: existingTitle,
      createdAt: existingIndex !== -1 ? notes[existingIndex].createdAt : now,
      updatedAt: now,
    }
    notes =
      existingIndex === -1
        ? [nextNote, ...notes]
        : notes.map((item) => (item.id === currentNoteId ? nextNote : item))
    await apiSaveNotes(currentProject, { list: notes, currentId: currentNoteId })
    await apiSaveNote(currentProject, { ...nextNote, content: noteContent })
  }

  function autoResizeTextarea(node) {
    if (!node) return
    node.style.height = 'auto'
    node.style.height = `${node.scrollHeight}px`
  }

  async function deleteNote(note) {
    if (!note?.id) return
    const confirmed = typeof window === 'undefined' || window.confirm('Удалить заметку?')
    if (!confirmed) return
    await apiDeleteNote(currentProject, note.id)
    notes = notes.filter((item) => item.id !== note.id)
    if (currentNoteId === note.id) {
      currentNoteId = ''
      noteContent = ''
    }
    await apiSaveNotes(currentProject, { list: notes, currentId: currentNoteId })
  }

  function getFilteredNotes() {
    const query = noteSearch.trim().toLowerCase()
    if (!query) return notes
    return notes.filter((item) => item.title.toLowerCase().includes(query))
  }

  async function startEditLetterName() {
    if (!currentLetterId) return
    letterNameDraft = currentLetterTitle || ''
    isEditingLetterName = true
    await tick()
    letterNameInput?.focus()
    letterNameInput?.select()
  }

  async function startEditLetterDate() {
    if (!currentLetterId) return
    letterDateDraft = currentLetterDate || ''
    isEditingLetterDate = true
    await tick()
    letterDateInput?.focus()
    if (letterDateInput?.showPicker) letterDateInput.showPicker()
  }

  function cancelEditLetterName() {
    isEditingLetterName = false
    letterNameDraft = ''
  }

  function cancelEditLetterDate() {
    isEditingLetterDate = false
    letterDateDraft = ''
  }

  async function persistCurrentLetter() {
    if (!currentLetterId) return
    const now = new Date().toISOString()
    const existingIndex = letters.findIndex((letter) => letter.id === currentLetterId)
    const nextLetter = {
      id: currentLetterId,
      name: buildLetterName(currentLetterTitle, currentLetterDate),
      title: currentLetterTitle,
      date: currentLetterDate,
      createdAt: existingIndex !== -1 ? letters[existingIndex].createdAt : now,
      updatedAt: now,
    }
    letters =
      existingIndex === -1
        ? [nextLetter, ...letters]
        : letters.map((letter) => (letter.id === currentLetterId ? nextLetter : letter))
    currentLetterName = nextLetter.name
    await apiSaveLetters(currentProject, { list: letters, currentId: currentLetterId })
    await apiSaveLetter(currentProject, { ...nextLetter, blocks: buildDraftPayload() })
    apiAppendLetterHistory(currentProject, currentLetterId, {
      savedAt: new Date().toISOString(),
      blocks: buildDraftPayload(),
    }).catch(() => {})
  }

  async function commitEditLetterName() {
    if (!currentLetterId) return
    const nextTitle = letterNameDraft.trim()
    if (!nextTitle) {
      cancelEditLetterName()
      return
    }
    currentLetterTitle = nextTitle
    isEditingLetterName = false
    await persistCurrentLetter()
  }

  async function commitEditLetterDate() {
    if (!currentLetterId) return
    const nextDate = letterDateDraft
    if (!nextDate) {
      cancelEditLetterDate()
      return
    }
    currentLetterDate = nextDate
    isEditingLetterDate = false
    await persistCurrentLetter()
  }

  function openLetterModal(mode = 'new') {
    letterModalMode = mode
    newLetterTitle = mode === 'update' ? currentLetterTitle || '' : ''
    newLetterDate = mode === 'update' ? currentLetterDate || '' : new Date().toISOString().slice(0, 10)
    newLetterError = ''
    showLetterModal = true
  }

  async function saveLetterFromModal() {
    const title = newLetterTitle.trim()
    const date = newLetterDate
    if (!title || !date) {
      newLetterError = 'Укажите название и дату отправки.'
      return
    }
    const now = new Date().toISOString()
    if (letterModalMode === 'update' && currentLetterId) {
      const name = buildLetterName(title, date)
      const existingIndex = letters.findIndex((letter) => letter.id === currentLetterId)
      const nextLetter = {
        id: currentLetterId,
        name,
        title,
        date,
        createdAt: existingIndex !== -1 ? letters[existingIndex].createdAt : now,
        updatedAt: now,
      }
      letters =
        existingIndex === -1
          ? [nextLetter, ...letters]
          : letters.map((letter) => (letter.id === currentLetterId ? nextLetter : letter))
      currentLetterName = name
      currentLetterTitle = title
      currentLetterDate = date
      showLetterModal = false
      setActivePage('builder')
      await apiSaveLetters(currentProject, { list: letters, currentId: currentLetterId })
      await apiSaveLetter(currentProject, { ...nextLetter, blocks: buildDraftPayload() })
      return
    }
    const id = createId()
    const name = buildLetterName(title, date)
    const nextLetter = {
      id,
      name,
      title,
      date,
      createdAt: now,
      updatedAt: now,
    }
    letters = [nextLetter, ...letters]
    currentLetterId = id
    currentLetterName = name
    currentLetterTitle = title
    currentLetterDate = date
    assembledBlocks = []
    rebuildOutput()
    showLetterModal = false
    setActivePage('builder')
    await apiSaveLetters(currentProject, { list: letters, currentId: currentLetterId })
    await apiSaveLetter(currentProject, { ...nextLetter, blocks: [] })
  }

  function requireLetter() {
    if (currentLetterId) return true
    openLetterModal('new')
    return false
  }

  function addBlock(block) {
    if (!requireLetter()) return
    pushHistory()
    let template = getBlockTemplate(block.name) || block.content
    if (isListBlock(block.name)) {
      template = trimListToFirstItem(template)
    }
    const newId = createId()
    assembledBlocks = [
      ...assembledBlocks,
      {
        id: newId,
        name: block.name,
        content: template,
        collapsed: false,
        schema: buildBaseSchema(template, block.name, settings?.mixinRules),
        addSpacing: true,
        spacing: undefined,
      },
    ]
    rebuildOutput()
    saveDraft()
    pendingFocusId = newId
    focusNewBlock()
  }

  function addSelectedBlock() {
    if (selectedBlockIndex === '' || selectedBlockIndex == null) return
    const blockIndex = Number(selectedBlockIndex)
    if (Number.isNaN(blockIndex)) return
    const block = allBlocks[blockIndex]
    if (!block) return
    addBlock(block)
    selectedBlockIndex = ''
  }


  function selectBlockFromDropdown(index) {
    addBlock(allBlocks[index])
    blockDropdownOpen = false
    blockDropdownSearch = ''
  }

  function getBlockTypeTag(block) {
    const n = (block.name || '').toLowerCase()
    const c = (block.content || '').toLowerCase()
    if (n.includes('product') || n.includes('товар') || n.includes('tovar') || c.includes('mixin-ids')) return 'Товары'
    if (n.includes('banner') || n.includes('баннер')) return 'Баннер'
    if (n.includes('button') || n.includes('кнопк') || n.includes('btn') || n.includes('cta')) return 'Кнопка'
    if (n.includes('header') || n.includes('заголов') || n.includes('hero')) return 'Заголовок'
    if (n.includes('list') || n.includes('спис') || c.includes('each ')) return 'Список'
    if (n.includes('divid') || n.includes('раздел') || n.includes('spacer') || n.includes('line')) return 'Разделитель'
    if (n.includes('footer') || n.includes('подвал')) return 'Подвал'
    if (c.includes('img(') || c.includes('image')) return 'Картинка'
    if (c.includes('<a') || c.includes('a(href')) return 'Текст'
    return 'Блок'
  }

  function getBlockPreviewLines(block) {
    if (!block.content) return []
    return block.content.split('\n').filter(l => l.trim()).slice(0, 3).map(l => l.trim().length > 50 ? l.trim().slice(0, 50) + '…' : l.trim())
  }

  function closeBlockDropdown(event) {
    if (blockDropdownRef && !blockDropdownRef.contains(event.target)) blockDropdownOpen = false
  }

  function findBlockIndexByName(name) {
    return allBlocks.findIndex((block) => block.name === name)
  }

  const defaultQuickBlocks = [
    'Заголовок зеленый',
    'Текст',
    'Банер',
    '3 товара в ряд',
    'Кнопка',
    'Две кнопки',
    'Разделитель',
  ]

  let quickBlockCandidate = ''
  let quickBlocksSelectRef
  let quickBlocksStatus = ''
  let quickBlocksError = ''
  let quickBlocksView = []

  function normalizeQuickBlocks(list) {
    const allowed = new Set(allBlocks.map((block) => block.name))
    return (list || []).filter((name) => allowed.has(name))
  }

  function ensureQuickBlocks(nextSettings) {
    const hasCustom = Array.isArray(nextSettings.quickBlocks)
    if (!hasCustom) {
      return { ...nextSettings, quickBlocks: defaultQuickBlocks.slice() }
    }
    return nextSettings
  }

  function getQuickBlocks(source = settings) {
    if (!source) return []
    if (Array.isArray(source.quickBlocks)) return source.quickBlocks
    return normalizeQuickBlocks(defaultQuickBlocks)
  }

  function getQuickBlockLabel(name) {
    if (name === 'Заголовок зеленый') return 'h2'
    if (name === 'Две кнопки') return '2 кнопки'
    if (name === 'Разделитель') return 'DIV'
    return name
  }

  function addQuickBlockByName(name) {
    const index = findBlockIndexByName(name)
    if (index === -1) return
    addBlock(allBlocks[index])
  }

  async function updateQuickBlocks(next) {
    settings = { ...settings, quickBlocks: next }
    quickBlocksView = next
    quickBlocksError = ''
    try {
      await apiSaveSettings(currentProject, settings)
      const fresh = await apiGetProject(currentProject)
      const normalizedSettings = ensureQuickBlocks(fresh.settings || { globalSpacing: 40, blocks: {} })
      settings = normalizedSettings
      quickBlocksView = normalizedSettings.quickBlocks || []
      quickBlocksStatus = 'Сохранено'
    } catch (err) {
      quickBlocksError = 'Ошибка сохранения'
    }
  }

  async function addQuickBlockFromSettings(nameInput) {
    const name = (nameInput || quickBlockCandidate || quickBlocksSelectRef?.value || '').trim()
    if (!name) {
      quickBlocksError = 'Выбери блок'
      return
    }
    const current = Array.isArray(settings.quickBlocks) ? settings.quickBlocks : normalizeQuickBlocks(defaultQuickBlocks)
    if (current.includes(name)) {
      quickBlockCandidate = ''
      quickBlocksError = 'Уже есть в списке'
      return
    }
    await updateQuickBlocks([...current, name])
    quickBlockCandidate = ''
  }

  async function removeQuickBlockFromSettings(name) {
    const current = Array.isArray(settings.quickBlocks) ? settings.quickBlocks : normalizeQuickBlocks(defaultQuickBlocks)
    await updateQuickBlocks(current.filter((item) => item !== name))
  }

  function addBlockAfter(index, blockIndex) {
    if (!requireLetter()) return
    const block = allBlocks[blockIndex]
    if (!block) return
    pushHistory()
    let template = getBlockTemplate(block.name) || block.content
    if (isListBlock(block.name)) {
      template = trimListToFirstItem(template)
    }
    const newId = createId()
    const next = {
      id: newId,
      name: block.name,
      content: template,
      collapsed: false,
      schema: buildBaseSchema(template, block.name, settings?.mixinRules),
      addSpacing: true,
      spacing: undefined,
    }
    assembledBlocks = [
      ...assembledBlocks.slice(0, index + 1),
      next,
      ...assembledBlocks.slice(index + 1),
    ]
    rebuildOutput()
    saveDraft()
    pendingFocusId = newId
    focusNewBlock()
  }

  async function focusNewBlock() {
    const id = pendingFocusId
    if (!id) return
    await tick()
    const node = document.querySelector(`[data-block-id="${id}"]`)
    if (node) {
      node.scrollIntoView({ behavior: 'smooth', block: 'center' })
      node.classList.add('flash')
      setTimeout(() => node.classList.remove('flash'), 500)
      setActiveBlock(id, true)
    }
    pendingFocusId = ''
  }

  function toggleAddMenu(id) {
    const next = addMenuFor === id ? null : id
    addMenuFor = next
    if (next) {
      tick().then(() => {
        const node = document.querySelector(`select[data-add-menu="${id}"]`)
        if (node?.showPicker) node.showPicker()
        else node?.focus()
      })
    }
  }

  function autosize(node) {
    const resize = () => {
      node.style.height = 'auto'
      node.style.height = `${node.scrollHeight}px`
    }
    resize()
    node.addEventListener('input', resize)
    return {
      destroy() {
        node.removeEventListener('input', resize)
      },
    }
  }

  // Match 2+ consecutive whitespace units (space, nbsp char, tab, &nbsp; entity)
  const DBL_SPACE_RE = /(?:&nbsp;|[ \t\u00A0\u2000-\u200A\u202F\u205F]){2,}/g
  const HAS_DBL_SPACE_RE = /(?:&nbsp;|[ \t\u00A0\u2000-\u200A\u202F\u205F]){2,}/

  function highlightDoubleSpaces(node, value) {
    let backdrop
    const isTextarea = node.tagName === 'TEXTAREA'
    function ensureBackdrop() {
      if (backdrop) return
      const wrap = node.parentNode
      if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative'
      backdrop = document.createElement('div')
      backdrop.className = 'dbl-space-backdrop'
      backdrop.setAttribute('aria-hidden', 'true')
      wrap.insertBefore(backdrop, node)
      node.classList.add('dbl-space-input')
      if (isTextarea) node.addEventListener('scroll', syncScroll)
    }
    function syncScroll() { if (backdrop) backdrop.scrollTop = node.scrollTop }
    function sync(val) {
      if (!HAS_DBL_SPACE_RE.test(val)) {
        if (backdrop) backdrop.style.display = 'none'
        node.classList.remove('dbl-space-input')
        return
      }
      ensureBackdrop()
      backdrop.style.display = ''
      node.classList.add('dbl-space-input')
      const cs = getComputedStyle(node)
      backdrop.style.font = cs.font
      backdrop.style.padding = cs.padding
      backdrop.style.lineHeight = cs.lineHeight
      backdrop.style.letterSpacing = cs.letterSpacing
      backdrop.style.whiteSpace = isTextarea ? 'pre-wrap' : 'pre'
      backdrop.style.wordWrap = isTextarea ? 'break-word' : 'normal'
      backdrop.style.width = node.offsetWidth + 'px'
      backdrop.style.height = node.offsetHeight + 'px'
      backdrop.style.borderWidth = cs.borderWidth
      backdrop.style.overflow = 'hidden'
      // Split by double-space matches, escape non-match parts, wrap matches in <mark>
      const parts = []
      let lastIdx = 0
      const esc = s => s.replace(/&(?!nbsp;)/g, '&amp;').replace(/</g, '&lt;')
      val.replace(DBL_SPACE_RE, (match, offset) => {
        parts.push(esc(val.slice(lastIdx, offset)))
        // Replace &nbsp; with actual nbsp char inside mark so it renders as space
        parts.push('<mark class="dbl-space-mark">' + match.replace(/&nbsp;/g, '\u00A0') + '</mark>')
        lastIdx = offset + match.length
      })
      parts.push(esc(val.slice(lastIdx)))
      backdrop.innerHTML = parts.join('')
    }
    sync(value)
    return {
      update(val) { sync(val) },
      destroy() {
        if (isTextarea) node.removeEventListener('scroll', syncScroll)
        if (backdrop) backdrop.remove()
        node.classList.remove('dbl-space-input')
      }
    }
  }

  function handleAddAfter(index, event) {
    const blockIndex = Number(event.target.value)
    if (Number.isNaN(blockIndex)) return
    addBlockAfter(index, blockIndex)
    addMenuFor = null
  }

  function togglePug(blockId) {
    pugOpen = { ...pugOpen, [blockId]: !pugOpen[blockId] }
  }

  function removeBlock(id) {
    pushHistory()
    assembledBlocks = assembledBlocks.filter((block) => block.id !== id)
    rebuildOutput()
    saveDraft()
  }

  function moveBlock(index, delta) {
    const nextIndex = index + delta
    if (nextIndex < 0 || nextIndex >= assembledBlocks.length) return
    pushHistory()
    const updated = [...assembledBlocks]
    const [item] = updated.splice(index, 1)
    updated.splice(nextIndex, 0, item)
    assembledBlocks = updated
    rebuildOutput()
    saveDraft()
  }

  let dragBlockIndex = -1
  let dragOverBlockIndex = -1

  function handleBlockDragStart(e, index) {
    const tag = e.target?.tagName?.toLowerCase()
    if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target?.isContentEditable) {
      e.preventDefault()
      return
    }
    dragBlockIndex = index
    e.dataTransfer.effectAllowed = 'move'
    e.dataTransfer.setData('text/plain', String(index))
  }

  function handleBlockDragOver(e, index) {
    e.preventDefault()
    e.dataTransfer.dropEffect = 'move'
    dragOverBlockIndex = index
  }

  function handleBlockDrop(e, index) {
    e.preventDefault()
    if (dragBlockIndex >= 0 && dragBlockIndex !== index) {
      pushHistory()
      const updated = [...assembledBlocks]
      const [item] = updated.splice(dragBlockIndex, 1)
      updated.splice(index, 0, item)
      assembledBlocks = updated
      rebuildOutput()
      saveDraft()
    }
    dragBlockIndex = -1
    dragOverBlockIndex = -1
  }

  function handleBlockDragEnd() {
    dragBlockIndex = -1
    dragOverBlockIndex = -1
  }

  function toggleBlock(id) {
    assembledBlocks = assembledBlocks.map((block) =>
      block.id === id ? { ...block, collapsed: !block.collapsed } : block
    )
    saveDraft()
  }

  function setAllCollapsed(value) {
    assembledBlocks = assembledBlocks.map((block) => ({ ...block, collapsed: value }))
    saveDraft()
  }

  // Visual product editor
  let visualBlocks = new Set()

  function parseProductsMixin(content) {
    if (!content) return null
    const trimmed = content.trim()
    const nameMatch = trimmed.match(/^\+(\w+)\s*\(\{/)
    if (!nameMatch) return null
    const mixinName = nameMatch[1]
    const start = trimmed.indexOf('({')
    const end = trimmed.lastIndexOf('})')
    if (start === -1 || end === -1 || end <= start) return null
    const body = trimmed.slice(start + 2, end)
    const products = []
    const entryRe = /['"]([^'"]+)['"]\s*:\s*\{([^}]*)\}/g
    let em
    while ((em = entryRe.exec(body)) !== null) {
      const id = em[1]
      const fields = em[2]
      const get = (key) => {
        const r = fields.match(new RegExp(key + "\\s*:\\s*(?:['\"]([^'\"]*)['\"]|(\\d+))"))
        return r ? (r[1] !== undefined ? r[1] : r[2] || '') : ''
      }
      products.push({ id, imageSrc: get('imageSrc'), name: get('name'), category: get('category'), oldprice: get('oldprice'), price: get('price'), discount: get('discount'), url: get('url') })
    }
    return products.length ? { mixinName, products } : null
  }

  function serializeProductsMixin(mixinName, products) {
    const indent = '  '
    const esc = s => String(s || '').replace(/'/g, "\\'")
    const lines = [`+${mixinName}({`]
    for (const p of products) {
      if (!p.id) continue
      lines.push(`${indent}'${p.id}': {`)
      lines.push(`${indent}${indent}imageSrc: '${esc(p.imageSrc)}',`)
      if (p.name)     lines.push(`${indent}${indent}name: '${esc(p.name)}',`)
      if (p.category) lines.push(`${indent}${indent}category: '${esc(p.category)}',`)
      if (p.oldprice) lines.push(`${indent}${indent}oldprice: ${p.oldprice},`)
      if (p.price)    lines.push(`${indent}${indent}price: ${p.price},`)
      if (p.discount) lines.push(`${indent}${indent}discount: '${p.discount}',`)
      if (p.url)      lines.push(`${indent}${indent}url: '${esc(p.url)}',`)
      lines.push(`${indent}},`)
    }
    lines.push('})')
    return lines.join('\n')
  }

  function updateProductField(blockId, productIndex, field, value) {
    const block = assembledBlocks.find(b => b.id === blockId)
    if (!block) return
    const parsed = parseProductsMixin(block.content)
    if (!parsed) return
    parsed.products[productIndex][field] = value
    updateBlockContent(blockId, serializeProductsMixin(parsed.mixinName, parsed.products))
  }

  function addProductRow(blockId) {
    const block = assembledBlocks.find(b => b.id === blockId)
    if (!block) return
    const parsed = parseProductsMixin(block.content)
    if (!parsed) return
    parsed.products.push({ id: '', imageSrc: '', name: '', category: '', oldprice: '', price: '', discount: '', url: '' })
    updateBlockContent(blockId, serializeProductsMixin(parsed.mixinName, parsed.products))
  }

  function removeProductRow(blockId, productIndex) {
    const block = assembledBlocks.find(b => b.id === blockId)
    if (!block) return
    const parsed = parseProductsMixin(block.content)
    if (!parsed) return
    parsed.products.splice(productIndex, 1)
    updateBlockContent(blockId, serializeProductsMixin(parsed.mixinName, parsed.products))
  }

  function toggleVisualMode(blockId) {
    if (visualBlocks.has(blockId)) {
      visualBlocks.delete(blockId)
    } else {
      visualBlocks.add(blockId)
    }
    visualBlocks = visualBlocks
  }

  function updateBlockContent(id, newContent) {
    pushHistory()
    assembledBlocks = assembledBlocks.map((block) =>
      block.id === id
        ? {
            ...block,
            content: newContent,
            schema: buildBaseSchema(newContent, block.name, settings?.mixinRules),
          }
        : block
    )
    rebuildOutput()
    saveDraft()
  }

  function moveSectionInBlock(blockId, sectionIdx, direction) {
    const block = assembledBlocks.find(b => b.id === blockId)
    if (!block) return
    const parsed = parseSections(block.content)
    if (!parsed) return
    // Apply move to ALL columns
    for (const col of parsed.columns) {
      const idxs = []
      col.sections.forEach((s, i) => { if (s.type === 'section') idxs.push(i) })
      const swapIdx = sectionIdx + direction
      if (sectionIdx < 0 || sectionIdx >= idxs.length || swapIdx < 0 || swapIdx >= idxs.length) continue
      const a = idxs[sectionIdx], b = idxs[swapIdx]
      const temp = col.sections[a]
      col.sections[a] = col.sections[b]
      col.sections[b] = temp
    }
    updateBlockContent(blockId, rebuildContentFromSections(parsed))
  }

  function removeSectionFromBlock(blockId, sectionIdx) {
    const block = assembledBlocks.find(b => b.id === blockId)
    if (!block) return
    const parsed = parseSections(block.content)
    if (!parsed) return
    // Apply remove to ALL columns
    for (const col of parsed.columns) {
      const idxs = []
      col.sections.forEach((s, i) => { if (s.type === 'section') idxs.push(i) })
      if (idxs.length <= 1 || sectionIdx >= idxs.length) continue
      const removePos = idxs[sectionIdx]
      col.sections.splice(removePos, 1)
      if (removePos > 0 && col.sections[removePos - 1]?.type === 'spacer') {
        col.sections.splice(removePos - 1, 1)
      } else if (col.sections[removePos]?.type === 'spacer') {
        col.sections.splice(removePos, 1)
      }
    }
    updateBlockContent(blockId, rebuildContentFromSections(parsed))
  }

  function removeButtonRow(blockId) {
    const block = assembledBlocks.find(b => b.id === blockId)
    if (!block) return
    const parsed = parseSections(block.content)
    if (!parsed) return
    for (const col of parsed.columns) {
      const idxs = col.sections.reduce((acc, s, i) => { if (s.type === 'section') acc.push(i); return acc }, [])
      const btnIdx = idxs.findIndex(i => col.sections[i].lines.some(l => l.trim().startsWith('+buttonRounded')))
      if (btnIdx === -1) continue
      const removePos = idxs[btnIdx]
      col.sections.splice(removePos, 1)
      if (removePos > 0 && col.sections[removePos - 1]?.type === 'spacer') {
        col.sections.splice(removePos - 1, 1)
      } else if (col.sections[removePos]?.type === 'spacer') {
        col.sections.splice(removePos, 1)
      }
    }
    updateBlockContent(blockId, rebuildContentFromSections(parsed))
  }

  function buildSectionHeaders(fields, parsed) {
    // Returns Map<fieldIndex, { section, si, total }> — only for the first field of each section
    const headers = new Map()
    if (!parsed || parsed.columns.length === 0) return headers
    const contentSecs = parsed.columns[0].sections.filter(s => s.type === 'section')
    if (contentSecs.length < 2) return headers
    const total = contentSecs.length
    const assigned = new Set()
    for (let fi = 0; fi < fields.length; fi++) {
      const fLine = fields[fi].lineIndex
      if (fLine == null) continue
      for (let si = 0; si < contentSecs.length; si++) {
        if (assigned.has(si)) continue
        const sec = contentSecs[si]
        const end = sec.startLine + sec.lines.length - 1
        if (fLine >= sec.startLine && fLine <= end) {
          headers.set(fi, { section: sec, si, total })
          assigned.add(si)
          break
        }
      }
    }
    return headers
  }

  function updateBlockSpacing(id, value) {
    pushHistory()
    assembledBlocks = assembledBlocks.map((block) =>
      block.id === id ? { ...block, addSpacing: value } : block
    )
    rebuildOutput()
    saveDraft()
  }

  function addTextParagraph(block, value = '') {
    pushHistory()
    const currentBlock = assembledBlocks.find((item) => item.id === block.id) ?? block
    const content = appendTextParagraph(currentBlock.content, value)
    assembledBlocks = assembledBlocks.map((item) =>
      item.id === block.id
        ? {
            ...item,
            content,
            schema: buildBaseSchema(content, item.name, settings?.mixinRules),
          }
        : item
    )
    rebuildOutput()
    saveDraft()
  }

  function trimListToFirstItem(content) {
    const lines = normalizeNewlines(content).trimEnd().split('\n')
    const ranges = getListItemRanges(lines)
    if (!ranges.length) return content
    const first = ranges[0]
    const tail = lines.slice(ranges[ranges.length - 1].end + 1)
    const nextLines = [
      ...lines.slice(0, first.start),
      ...lines.slice(first.start, first.end + 1),
      ...tail,
    ]
    return nextLines.join('\n')
  }

  function addListItem(block) {
    pushHistory()
    const currentBlock = assembledBlocks.find((item) => item.id === block.id) ?? block
    const lines = normalizeNewlines(currentBlock.content).trimEnd().split('\n')
    const ranges = getListItemRanges(lines)
    if (!ranges.length) return
    const last = ranges[ranges.length - 1]
    const itemLines = lines.slice(last.start, last.end + 1).map((line) => {
      if (!line.includes('span.text')) return line
      const meta = splitTextField(line)
      if (!meta) return line
      return line.slice(0, meta.start) + '' + line.slice(meta.end)
    })
    const tail = lines.slice(last.end + 1)
    const updated = [...lines.slice(0, last.end + 1), ...itemLines, ...tail].join('\n')
    assembledBlocks = assembledBlocks.map((item) =>
      item.id === block.id
        ? { ...item, content: updated, schema: buildBaseSchema(updated, item.name, settings?.mixinRules) }
        : item
    )
    rebuildOutput()
    saveDraft()
  }

  function removeListItem(block, field) {
    pushHistory()
    const currentBlock = assembledBlocks.find((item) => item.id === block.id) ?? block
    const lines = normalizeNewlines(currentBlock.content).split('\n')
    const ranges = getListItemRanges(lines)
    const target = ranges[field.itemIndex]
    if (!target) return
    const updated = [...lines.slice(0, target.start), ...lines.slice(target.end + 1)].join('\n').trimEnd()
    assembledBlocks = assembledBlocks.map((item) =>
      item.id === block.id
        ? { ...item, content: updated, schema: buildBaseSchema(updated, item.name, settings?.mixinRules) }
        : item
    )
    rebuildOutput()
    saveDraft()
  }

  function updateBlockSpacingValue(id, value) {
    pushHistory()
    if (value === '') {
      assembledBlocks = assembledBlocks.map((block) =>
        block.id === id ? { ...block, spacing: undefined } : block
      )
      rebuildOutput()
      saveDraft()
      return
    }
    const spacing = Number(value)
    if (Number.isNaN(spacing)) return
    assembledBlocks = assembledBlocks.map((block) =>
      block.id === id ? { ...block, spacing } : block
    )
    rebuildOutput()
    saveDraft()
  }

  const MAX_HISTORY = 50
  function pushHistory() {
    history = [
      ...history.slice(-(MAX_HISTORY - 1)),
      {
        blocks: JSON.parse(JSON.stringify(assembledBlocks)),
        settings: JSON.parse(JSON.stringify(settings)),
      },
    ]
  }

  function undoLast() {
    if (!history.length) return
    const prev = history[history.length - 1]
    history = history.slice(0, -1)
    assembledBlocks = prev.blocks
    settings = prev.settings
    apiSaveSettings(currentProject, settings)
    rebuildOutput()
    saveDraft()
  }

  function rebuildOutput() {
    let text = ''
    const ranges = {}
    assembledBlocks.forEach((block, index) => {
      const blockText = `//${block.name}\n${block.content.trimEnd()}`
      if (text) text += '\n\n'
      const start = text.length
      text += blockText
      const end = text.length
      ranges[block.id] = { start, end }
      if (shouldAddSpacing(block, assembledBlocks[index + 1])) {
        text += `\n\n+spacerLine(${getSpacingForBlock(block)})`
      }
    })
    outputPug = text
    blockRanges = ranges
    if (activeBlockId) {
      scrollToBlock(activeBlockId)
    }
    scheduleAutoRender()
  }

  function scheduleAutoRender() {
    if (outputPanelMode !== 'preview' && outputPanelMode !== 'html') return
    clearTimeout(autoRenderTimer)
    autoRenderTimer = setTimeout(() => {
      if (!previewLoading && previewStale) renderEmailPreview()
    }, 2500)
  }

  function setActiveBlock(id, focusPug = false) {
    activeBlockId = id
    scrollToBlock(id, focusPug)
  }

  async function scrollToBlock(id, focusPug = false) {
    await tick()
    const range = blockRanges[id]
    if (!pugOutputRef || !range) return
    const lineStart = outputPug.slice(0, range.start).split('\n').length - 1
    const lineHeight = parseFloat(getComputedStyle(pugOutputRef).lineHeight) || 16
    pugOutputRef.scrollTop = Math.max(0, (lineStart - 3) * lineHeight)
    pugOutputRef.setSelectionRange(range.start, range.end)
    if (focusPug) pugOutputRef.focus()
  }

  function shouldAddSpacing(block, nextBlock) {
    if (!block.addSpacing) return false
    if (!nextBlock) return true
    if (block.name === 'Отступ 20') return false
    if (block.name === 'Отступ 40') return false
    if (getBlockSetting(block.name).spacingEnabled === false) return false
    return true
  }

  function getSpacingForBlock(blockOrName) {
    if (blockOrName && typeof blockOrName === 'object') {
      if (typeof blockOrName.spacing === 'number') return blockOrName.spacing
    }
    const blockName = typeof blockOrName === 'string' ? blockOrName : blockOrName?.name
    const blockSetting = getBlockSetting(blockName)
    if (typeof blockSetting.spacing === 'number') return blockSetting.spacing
    return settings.globalSpacing ?? 40
  }

  let saveSettingsQueue = Promise.resolve()
  function saveSettings(next) {
    settings = next
    // Serialize saves to avoid races (last-write wins on stale snapshot)
    saveSettingsQueue = saveSettingsQueue.then(() => apiSaveSettings(currentProject, next)).catch(() => {})
    return saveSettingsQueue
  }

  function updateSettingsBlock(name, patch) {
    const current = settings.blocks?.[name] ?? {}
    saveSettings({
      ...settings,
      blocks: {
        ...settings.blocks,
        [name]: { ...current, ...patch },
      },
    })
  }

  function addProductOption() {
    const next = [...(settings.productOptions || []), { label: '', code: '' }]
    saveSettings({ ...settings, productOptions: next })
  }

  function updateProductOption(index, patch) {
    const next = [...(settings.productOptions || [])]
    next[index] = { ...next[index], ...patch }
    saveSettings({ ...settings, productOptions: next })
  }

  function removeProductOption(index) {
    const next = [...(settings.productOptions || [])]
    next.splice(index, 1)
    saveSettings({ ...settings, productOptions: next })
  }

  function buildDefaultMixinRulesFromBlocks(blocks) {
    const map = new Map()
    const pushRule = (rule) => {
      const key = `${rule.mixin}:${rule.argIndex}:${rule.type}`
      if (map.has(key)) return
      map.set(key, rule)
    }

    ;(blocks || []).forEach((block) => {
      const lines = normalizeNewlines(block.content || '').split('\n')
      lines.forEach((line) => {
        const trimmed = line.trim()
        if (!trimmed.startsWith('+')) return
        const mixin = getMixinName(trimmed)
        if (!mixin) return
        const argCount = getMixinArgCount(trimmed)
        if (!argCount) return
        DEFAULT_MIXIN_RULE_TEMPLATES.forEach((template) => {
          if (!template.test(mixin)) return
          template.fields.forEach((field) => {
            if (field.argIndex >= argCount) return
            pushRule({
              mixin,
              argIndex: field.argIndex,
              type: field.type,
              label: field.label,
            })
          })
        })
      })
    })

    return normalizeMixinRules(Array.from(map.values()))
  }

  function addMixinRule() {
    const next = [...(settings.mixinRules || []), { mixin: '', argIndex: 0, type: 'mixin-text', label: '' }]
    saveSettings({ ...settings, mixinRules: next, mixinRulesInitialized: true })
  }

  function updateMixinRule(index, patch) {
    const next = [...(settings.mixinRules || [])]
    const current = next[index] || { mixin: '', argIndex: 0, type: 'mixin-text', label: '' }
    next[index] = { ...current, ...patch }
    if (patch.argIndex !== undefined) {
      const parsed = Number(patch.argIndex)
      next[index].argIndex = Number.isNaN(parsed) ? 0 : Math.max(0, parsed)
    }
    if (patch.mixin !== undefined) {
      next[index].mixin = String(patch.mixin || '').trim()
    }
    if (patch.label !== undefined) {
      next[index].label = String(patch.label || '').trim()
    }
    saveSettings({ ...settings, mixinRules: normalizeMixinRules(next), mixinRulesInitialized: true })
  }

  function removeMixinRule(index) {
    const next = [...(settings.mixinRules || [])]
    next.splice(index, 1)
    saveSettings({ ...settings, mixinRules: next, mixinRulesInitialized: true })
  }

  let addingCustomBlock = false
  async function addCustomBlock() {
    if (addingCustomBlock) return
    const name = customBlockName.trim()
    if (!name) { customBlockError = 'Введи название блока'; return }
    const allNames = new Set([
      ...availableBlocks.map(b => b.name),
      ...(settings.customBlocks || []).map(b => b.name),
    ])
    if (allNames.has(name)) { customBlockError = 'Блок с таким именем уже существует'; return }
    const content = customBlockContent.trim()
    if (!content) { customBlockError = 'Введи код блока'; return }
    addingCustomBlock = true
    try {
      const newBlock = { id: createId(), name, content }
      const next = [...(settings.customBlocks || []), newBlock]
      await saveSettings({ ...settings, customBlocks: next })
      customBlockName = ''
      customBlockContent = ''
      customBlockError = ''
    } finally {
      addingCustomBlock = false
    }
  }

  function updateCustomBlock(id, patch) {
    const current = (settings.customBlocks || [])
    const old = current.find(cb => cb.id === id)
    if (!old) return
    if (patch.name !== undefined) {
      const name = patch.name.trim()
      const otherNames = new Set([
        ...availableBlocks.map(b => b.name),
        ...current.filter(cb => cb.id !== id).map(cb => cb.name),
      ])
      if (otherNames.has(name)) { customBlockError = 'Блок с таким именем уже существует'; return }
    }
    const next = current.map(cb => cb.id === id ? { ...cb, ...patch } : cb)
    const updated = next.find(cb => cb.id === id)
    if (patch.name && patch.name !== old.name) {
      assembledBlocks = assembledBlocks.map(ab =>
        ab.name === old.name ? { ...ab, name: patch.name } : ab
      )
      const nextSettings = { ...settings, customBlocks: next }
      // Migrate settings.blocks from old name to new name
      const blockSettings = settings.blocks?.[old.name]
      if (blockSettings) {
        nextSettings.blocks = { ...nextSettings.blocks, [patch.name]: blockSettings }
        delete nextSettings.blocks[old.name]
      }
      if (Array.isArray(settings.quickBlocks)) {
        nextSettings.quickBlocks = settings.quickBlocks.map(n => n === old.name ? patch.name : n)
      }
      saveSettings(nextSettings)
    } else {
      saveSettings({ ...settings, customBlocks: next })
    }
    if (patch.content !== undefined && updated) {
      assembledBlocks = assembledBlocks.map(ab =>
        ab.name === updated.name ? {
          ...ab,
          content: patch.content,
          schema: buildBaseSchema(patch.content, updated.name, settings?.mixinRules),
        } : ab
      )
      rebuildOutput()
      saveDraft()
    }
    customBlockError = ''
  }

  function removeCustomBlock(id) {
    const block = (settings.customBlocks || []).find(cb => cb.id === id)
    const next = (settings.customBlocks || []).filter(cb => cb.id !== id)
    const nextSettings = { ...settings, customBlocks: next }
    if (block && Array.isArray(settings.quickBlocks) && settings.quickBlocks.includes(block.name)) {
      nextSettings.quickBlocks = settings.quickBlocks.filter(n => n !== block.name)
    }
    saveSettings(nextSettings)
    if (editingCustomBlockId === id) editingCustomBlockId = null
  }

  function maybeAutoInitMixinRules() {
    if (settings.mixinRulesInitialized) return
    const defaults = buildDefaultMixinRulesFromBlocks(allBlocks)
    if (!defaults.length) return
    saveSettings({
      ...settings,
      mixinRules: defaults,
      mixinRulesInitialized: true,
    })
  }

  function updateProjectTitle(value) {
    saveSettings({ ...settings, projectTitle: value })
  }

  function updateEmailGenProject(value) {
    saveSettings({ ...settings, emailGenProject: value })
  }

  function updateAccentColor(value) {
    const next = value || '#6b7280'
    settings = { ...settings, accentColor: next }
    saveSettings(settings)
    setAccentColor(next)
  }

  async function handleLogoUpload(event) {
    const file = event.target.files?.[0]
    if (!file) return
    const reader = new FileReader()
    reader.onload = () => {
      const result = String(reader.result || '')
      if (!result) return
      saveSettings({ ...settings, logoData: result })
    }
    reader.readAsDataURL(file)
    event.target.value = ''
  }

  function removeLogo() {
    const next = { ...settings }
    delete next.logoData
    saveSettings(next)
  }

  function getBlockSetting(name) {
    return settings.blocks?.[name] ?? {}
  }

  function getBlockTemplate(name) {
    const override = getBlockSetting(name).template
    if (typeof override === 'string' && override.trim() !== '') return override
    const original = allBlocks.find((block) => block.name === name)?.content
    return original ?? ''
  }

  function getEffectiveTemplate(name) {
    const override = getBlockSetting(name).template
    if (typeof override === 'string' && override.trim() !== '') return override
    return getBlockTemplate(name)
  }

  function fieldSignature(field) {
    const slot = `${field.argIndex ?? field.occurrence ?? 0}:${field.objectKey ?? ''}`
    return `${field.type}:${field.lineIndex ?? 'raw'}:${slot}`
  }

  function applySchemaSettings(schema, blockName) {
    const blockSetting = getBlockSetting(blockName)
    const overrides = blockSetting.fields ?? {}
    const extraFields = blockSetting.extraFields ?? []
    return [
      ...extraFields,
      ...schema
        .filter((field) => overrides[fieldSignature(field)]?.enabled !== false)
        .map((field) => ({
          ...field,
          label: overrides[fieldSignature(field)]?.label || field.label,
        })),
    ]
  }

  function ensureSchema(block) {
    const schema = buildBaseSchema(block.content, block.name, settings?.mixinRules)
    block.schema = schema
    return schema
  }

  function extractFirstTextValue(content) {
    const lines = normalizeNewlines(content).split('\n')
    for (const line of lines) {
      const meta = getParagraphMeta(line)
      if (meta && meta.value.trim() !== '') return meta.value
    }
    return ''
  }

  function setFirstTextValue(content, value) {
    const lines = normalizeNewlines(content).split('\n')
    for (let index = 0; index < lines.length; index += 1) {
      const meta = getParagraphMeta(lines[index])
      if (meta) {
        lines[index] = lines[index].slice(0, meta.start) + value + lines[index].slice(meta.end)
        return lines.join('\n')
      }
    }
    return content
  }

  function appendTextParagraph(content, value) {
    const lines = normalizeNewlines(content).trimEnd().split('\n')
    const spanIndex = lines.findIndex((line) => Boolean(getParagraphMeta(line)))
    if (spanIndex === -1) return content
    const spanLine = lines[spanIndex]
    const spanIndent = spanLine.match(/^\s*/)?.[0] ?? ''
    const tdIndent = spanIndent.slice(0, Math.max(0, spanIndent.length - 4))
    const trIndent = spanIndent.slice(0, Math.max(0, spanIndent.length - 8))
    const spacerLine = lines.find((line) => line.trim().startsWith('+spacerLine'))
    const spacerIndent = spacerLine ? spacerLine.match(/^\s*/)?.[0] ?? '' : trIndent
    const tdLine = lines[spanIndex - 1] ?? `${tdIndent}td`
    const trLine = lines[spanIndex - 2] ?? `${trIndent}tr`
    const meta = getParagraphMeta(spanLine)
    const spanPrefix = meta ? spanLine.slice(0, meta.start) : `${spanLine.trimEnd()} `

    const appended = [
      `${spacerIndent}+spacerLine(20)`,
      trLine.trimEnd(),
      tdLine.trimEnd(),
      `${spanPrefix}${value}`,
    ]

    return `${lines.join('\n')}\n${appended.join('\n')}`
  }

  function removeTextParagraph(block, field) {
    pushHistory()
    const lines = normalizeNewlines(block.content).split('\n')
    const spanIndex = field.lineIndex
    if (spanIndex == null || spanIndex < 0 || spanIndex >= lines.length) return

    let removeStart = spanIndex - 2
    let removeEnd = spanIndex

    if (removeStart < 0) removeStart = spanIndex

    const spacerIndex = spanIndex - 3
    if (spacerIndex >= 0 && lines[spacerIndex].trim().startsWith('+spacerLine')) {
      removeStart = spacerIndex
    }

    const nextLines = [
      ...lines.slice(0, removeStart),
      ...lines.slice(removeEnd + 1),
    ]

    const content = nextLines.join('\n').trimEnd()
    assembledBlocks = assembledBlocks.map((item) =>
      item.id === block.id
        ? {
            ...item,
            content,
            schema: buildBaseSchema(content, block.name, settings?.mixinRules),
          }
        : item
    )
    rebuildOutput()
    saveDraft()
  }

  function normalizeDraftBlocks(draft) {
    const normalized = []
    draft.forEach((block) => {
      if (isExtraTextBlock(block.name)) {
        const extraText = extractFirstTextValue(block.content)
        const last = normalized[normalized.length - 1]
        if (last && last.name === 'Текст') {
          const mergedContent = appendTextParagraph(last.content, extraText)
          normalized[normalized.length - 1] = {
            ...last,
            content: mergedContent,
            schema: undefined,
          }
          return
        }
        const template = getBlockTemplate('Текст')
        if (template) {
          const withText = setFirstTextValue(template, extraText)
          normalized.push({
            ...block,
            name: 'Текст',
            content: withText,
            schema: undefined,
          })
          return
        }
      }
      normalized.push(block)
    })
    return normalized
  }

  function getProductLineInfo(content) {
    const lines = normalizeNewlines(content).split('\n')
    for (let i = 0; i < lines.length; i += 1) {
      const line = lines[i]
      if (line.trim().startsWith('+products')) {
        return { lineIndex: i, line }
      }
    }
    return { lineIndex: -1, line: '' }
  }

  function getProductOptionsFromLine(line) {
    const objStart = line.indexOf('{')
    const objEnd = line.lastIndexOf('}')
    if (objStart === -1 || objEnd === -1 || objEnd < objStart) {
      return { options: {}, hasObject: false, objStart: -1, objEnd: -1 }
    }
    const inside = line.slice(objStart + 1, objEnd)
    return { options: parseOptionsObject(inside), hasObject: true, objStart, objEnd }
  }

  function updateProductOptionsInLine(line, key, value, enabled) {
    const info = getProductOptionsFromLine(line)
    const options = { ...info.options }
    if (enabled) {
      options[key] = value
    } else {
      delete options[key]
    }
    const objString = stringifyOptions(options)
    if (!objString) {
      if (!info.hasObject) return line
      const prefix = line.slice(0, info.objStart).replace(/,\s*$/, '')
      const suffix = line.slice(info.objEnd + 1)
      return `${prefix}${suffix}`.replace(/\s+\)/, ')')
    }
    if (info.hasObject) {
      return line.slice(0, info.objStart + 1) + objString + line.slice(info.objEnd)
    }
    const endArgs = line.lastIndexOf(')')
    if (endArgs === -1) return line
    const before = line.slice(0, endArgs)
    const after = line.slice(endArgs)
    return `${before}, {${objString}}${after}`
  }

  function getProductOptionState(block) {
    const { line } = getProductLineInfo(block.content)
    if (!line) return {}
    return getProductOptionsFromLine(line).options
  }

  function updateProductOptionForBlock(block, option, enabled) {
    const parsed = parseOptionCode(option.code)
    if (!parsed) return
    const lines = normalizeNewlines(block.content).split('\n')
    const { lineIndex, line } = getProductLineInfo(block.content)
    if (lineIndex === -1) return
    const updatedLine = updateProductOptionsInLine(line, parsed.key, parsed.value, enabled)
    lines[lineIndex] = updatedLine
    updateBlockContent(block.id, lines.join('\n'))
  }

  function toggleProductOptionForBlock(block, option) {
    const parsed = parseOptionCode(option.code)
    if (!parsed) return
    const state = getProductOptionState(block)
    const current = state[parsed.key]
    const enabled = current !== parsed.value
    updateProductOptionForBlock(block, option, enabled)
  }

  function getProductOptionsList() {
    return (settings.productOptions || []).map((opt) => ({
      label: opt.label || opt.code,
      code: opt.code || '',
    })).filter((opt) => opt.code)
  }

  function isOptionEnabledInAllBlocks(option) {
    const parsed = parseOptionCode(option.code)
    if (!parsed) return false
    const productBlocks = assembledBlocks.filter((block) => isProductBlock(block.content))
    if (!productBlocks.length) return false
    return productBlocks.every((block) => getProductOptionState(block)[parsed.key] === parsed.value)
  }

  function isOptionEnabledInAnyBlock(option) {
    const parsed = parseOptionCode(option.code)
    if (!parsed) return false
    const productBlocks = assembledBlocks.filter((block) => isProductBlock(block.content))
    if (!productBlocks.length) return false
    return productBlocks.some((block) => getProductOptionState(block)[parsed.key] === parsed.value)
  }

  function toggleProductOptionForAll(option) {
    const enable = !isOptionEnabledInAllBlocks(option)
    assembledBlocks.forEach((block) => {
      if (!isProductBlock(block.content)) return
      updateProductOptionForBlock(block, option, enable)
    })
  }

  function getFieldValue(block, field) {
    if (field.type === 'raw') return block.content.trimEnd()
    const lines = normalizeNewlines(block.content).split('\n')
    const line = lines[field.lineIndex] ?? ''
    if (field.objectKey) {
      const range = findMixinArgRange(line, field.argIndex)
      if (!range) return ''
      const rawArg = line.slice(range.start, range.end)
      const props = parseMixinObjectArg(rawArg)
      const found = props.find((item) => item.key === field.objectKey)
      return found?.value ?? ''
    }
    if (field.type === 'select') {
      const m = line.match(/'([^']*)'/)
      return m ? m[1] : (field.options?.[0]?.value ?? '')
    }
    if (field.type === 'mixin-text' || field.type === 'mixin-href' || field.type === 'mixin-ids' || field.type === 'mixin-opts') {
      return extractMixinArgValue(line, field.argIndex)
    }
    if (field.type === 'text' || field.type === 'list-item') {
      const meta = splitTextField(line)
      return meta ? meta.value : ''
    }
    const linkRegex = /(href|src)=("([^"]*)"|'([^']*)')/g
    let match
    let occurrence = 0
    while ((match = linkRegex.exec(line))) {
      if (occurrence === field.occurrence) {
        return match[3] ?? match[4] ?? ''
      }
      occurrence += 1
    }
    return ''
  }

  function updateField(block, field, newValue) {
    if (field.type === 'raw') {
      updateBlockContent(block.id, newValue)
      return
    }
    if (field.type === 'select') {
      const lines = normalizeNewlines(block.content).split('\n')
      lines[field.lineIndex] = lines[field.lineIndex].replace(/'[^']*'/, `'${newValue}'`)
      updateBlockContent(block.id, lines.join('\n'))
      return
    }
    // Prevent newlines from breaking Pug structure — replace with <br> for text, space for others
    if (newValue.includes('\n')) {
      newValue = (field.type === 'text' || field.type === 'list-item')
        ? newValue.replace(/\r?\n/g, '<br>')
        : newValue.replace(/\r?\n/g, ' ')
    }
    const lines = normalizeNewlines(block.content).split('\n')
    const line = lines[field.lineIndex] ?? ''
    if (field.objectKey) {
      const range = findMixinArgRange(line, field.argIndex)
      if (!range) return
      const rawArg = line.slice(range.start, range.end)
      const updatedArg = replaceObjectPropInArg(rawArg, field.objectKey, newValue)
      lines[field.lineIndex] = line.slice(0, range.start) + updatedArg + line.slice(range.end)
      updateBlockContent(block.id, lines.join('\n'))
      return
    }
    if (field.type === 'mixin-text' || field.type === 'mixin-href' || field.type === 'mixin-ids' || field.type === 'mixin-opts') {
      lines[field.lineIndex] =
        field.type === 'mixin-opts'
          ? replaceMixinArgRaw(line, field.argIndex, newValue)
          : replaceMixinArgQuoted(line, field.argIndex, newValue)
      updateBlockContent(block.id, lines.join('\n'))
      return
    }
    if (field.type === 'text' || field.type === 'list-item') {
      const meta = splitTextField(line)
      if (meta) {
        lines[field.lineIndex] = line.slice(0, meta.start) + newValue + line.slice(meta.end)
      } else {
        lines[field.lineIndex] = line ? `${line} ${newValue}` : newValue
      }
    } else {
      const linkRegex = /(href|src)=("([^"]*)"|'([^']*)')/g
      let match
      let occurrence = 0
      let updated = line
      while ((match = linkRegex.exec(line))) {
        if (occurrence === field.occurrence) {
          const value = match[3] ?? match[4] ?? ''
          const valueIndex = match[1].length + 2
          const start = match.index + valueIndex
          const end = start + value.length
          updated = line.slice(0, start) + newValue + line.slice(end)
          break
        }
        occurrence += 1
      }
      lines[field.lineIndex] = updated
    }
    updateBlockContent(block.id, lines.join('\n'))
  }

  function handleImageDragOver(event, block, field) {
    event.preventDefault()
    event.dataTransfer.dropEffect = 'copy'
    dragOverField = fieldKey(block, field)
  }

  function handleImageDragLeave(event) {
    if (!event.currentTarget.contains(event.relatedTarget)) dragOverField = null
  }

  async function handleImageDrop(event, block, field) {
    event.preventDefault()
    dragOverField = null
    const file = event.dataTransfer?.files?.[0]
    if (!file || !file.type.startsWith('image/')) return
    const fk = fieldKey(block, field)
    if (file.size > 20 * 1024 * 1024) {
      uploadingFields = { ...uploadingFields, [fk]: { loading: false, error: 'Макс. 20 МБ' } }
      return
    }
    uploadingFields = { ...uploadingFields, [fk]: { loading: true, error: '' } }
    try {
      const dataUrl = await new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.onload = () => resolve(reader.result)
        reader.onerror = () => reject(new Error('Не удалось прочитать файл'))
        reader.readAsDataURL(file)
      })
      if ((settings.ftpConfig?.host || settings.imageStorage === 'local') && currentLetterDate) {
        const folder = getLetterDateFolder()
        const baseName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_-]/g, '_')
        const fileName = `${baseName}-${Date.now().toString(36)}`
        const result = await apiFtpUpload(currentProject, { imageData: dataUrl, fileName, folder })
        updateField(block, field, result.url)
      } else {
        const result = await apiUploadImage(dataUrl, file.name, currentProject)
        updateField(block, field, result.url)
      }
      uploadingFields = { ...uploadingFields, [fk]: { loading: false, error: '' } }
    } catch (e) {
      uploadingFields = { ...uploadingFields, [fk]: { loading: false, error: e?.message || 'Ошибка загрузки' } }
    }
  }

  function fieldKey(block, field) {
    const slot = `${field.argIndex ?? field.occurrence ?? 0}:${field.objectKey ?? ''}`
    return `${block.id}:${field.type}:${field.lineIndex ?? 'raw'}:${slot}`
  }

  function isImageField(field) {
    if (field.type === 'src') return true
    if (field.label === 'Картинка') return true
    const label = (field.label || '').toLowerCase()
    return field.type === 'mixin-href' && (label.includes('картинк') || label.includes('image') || label.includes('banner'))
  }

  function looksLikeImageUrl(value) {
    return value ? /^https?:\/\/.+\.(png|jpe?g|gif|webp|svg)(\?|#|$)/i.test(value.trim()) : false
  }

  function registerTextField(key, node) {
    if (!node) return
    textFieldRefs = { ...textFieldRefs, [key]: node }
  }

  function unregisterTextField(key) {
    if (!textFieldRefs[key]) return
    const next = { ...textFieldRefs }
    delete next[key]
    textFieldRefs = next
  }

  function textRef(node, key) {
    registerTextField(key, node)
    return {
      update(nextKey) {
        if (nextKey !== key) {
          unregisterTextField(key)
          key = nextKey
          registerTextField(key, node)
        }
      },
      destroy() {
        unregisterTextField(key)
      },
    }
  }

  function wrapBold(block, field) {
    const key = fieldKey(block, field)
    const input = textFieldRefs[key]
    const current = getFieldValue(block, field)
    if (!input) {
      updateField(block, field, `${current}<span style="font-weight: 700;"></span>`)
      return
    }
    const start = input.selectionStart ?? current.length
    const end = input.selectionEnd ?? current.length
    const before = current.slice(0, start)
    const middle = current.slice(start, end)
    const after = current.slice(end)
    const wrapped = `<span style="font-weight: 700;">${middle}</span>`
    updateField(block, field, `${before}${wrapped}${after}`)
  }

  function insertBullet(block, field) {
    const key = fieldKey(block, field)
    const input = textFieldRefs[key]
    const current = getFieldValue(block, field)
    if (!input) {
      updateField(block, field, `${current}<br>&bull;`)
      return
    }
    const start = input.selectionStart ?? current.length
    const end = input.selectionEnd ?? current.length
    const before = current.slice(0, start)
    const after = current.slice(end)
    updateField(block, field, `${before}<br>&bull;${after}`)
  }

  function insertBr(block, field) {
    const key = fieldKey(block, field)
    const input = textFieldRefs[key]
    const current = getFieldValue(block, field)
    if (!input) {
      updateField(block, field, `${current}<br>`)
      return
    }
    const pos = input.selectionStart ?? current.length
    // Trim spaces around cursor
    let left = pos
    while (left > 0 && current[left - 1] === ' ') left--
    let right = pos
    while (right < current.length && current[right] === ' ') right++
    const before = current.slice(0, left)
    const after = current.slice(right)
    updateField(block, field, `${before}<br>${after}`)
  }

  function updateLinkDraft(key, value) {
    linkDrafts = { ...linkDrafts, [key]: value }
  }

  function togglePreview(key) {
    previewOpen = { ...previewOpen, [key]: !previewOpen[key] }
  }

  function setTheme(value) {
    theme = value
    localStorage.setItem('z51-theme', theme)
    if (currentUser) apiSavePreferences({ theme }).catch(() => {})
  }

  function setActivePage(next) {
    activePage = next
    if (typeof localStorage !== 'undefined') {
      localStorage.setItem('z51-active-page', next)
    }
    if (next === 'plan') loadPlan()
  }

  // --- Google Sheets / Plan functions ---
  async function loadSheetsAuthStatus() {
    try {
      sheetsConfig = await apiGetConfig()
      sheetsClientIdDraft = sheetsConfig.client_id || ''
      sheetsAuthStatus = await apiGetSheetsAuthStatus()
      uploadBaseUrl = sheetsConfig.upload_base_url || ''
    } catch { sheetsAuthStatus = { configured: false, authorized: false } }
  }

  async function saveUploadConfig() {
    uploadSaving = true; uploadConfigError = ''; uploadConfigStatus = ''
    try {
      await apiSaveConfig({ upload_base_url: uploadBaseUrl.trim() })
      uploadConfigStatus = 'Сохранено'
      setTimeout(() => { uploadConfigStatus = '' }, 3000)
    } catch (e) { uploadConfigError = e.message }
    uploadSaving = false
  }

  async function saveSheetsCredentials() {
    sheetsSavingConfig = true; sheetsConfigError = ''
    try {
      await apiSaveConfig({ client_id: sheetsClientIdDraft.trim(), client_secret: sheetsClientSecretDraft.trim() || undefined })
      sheetsClientSecretDraft = ''
      await loadSheetsAuthStatus()
    } catch (err) { sheetsConfigError = err.message }
    sheetsSavingConfig = false
  }

  function openOAuthPopup() {
    window.open('/api/sheets/auth', '_blank', 'width=600,height=700,noopener')
  }

  function saveSheetsProjectConfig() {
    const sc = {
      spreadsheetId: sheetsCfgSpreadsheetId.trim(),
      sheetName: sheetsCfgSheetName.trim(),
      columns: {
        date: sheetsCfgDateCol.trim().toUpperCase() || 'A',
        subject: sheetsCfgSubjectCol.trim().toUpperCase() || 'D',
        preheader: sheetsCfgPreheaderCol.trim().toUpperCase() || 'E',
        status: sheetsCfgStatusCol.trim().toUpperCase() || 'F',
      },
      statusFilter: sheetsCfgStatusFilter.split(',').map((s) => s.trim()).filter(Boolean),
      headerRows: Number(sheetsCfgHeaderRows) || 1,
      extraColumns: sheetsCfgExtraCols.filter(c => c.col?.trim()),
    }
    saveSettings({ ...settings, sheetsConfig: sc })
  }

  async function testSheetsConnection() {
    sheetsTestLoading = true; sheetsTestResult = ''
    try {
      saveSheetsProjectConfig()
      const sc = settings.sheetsConfig
      if (!sc?.spreadsheetId || !sc?.sheetName) { sheetsTestResult = 'error:Укажи Spreadsheet ID и название листа'; sheetsTestLoading = false; return }
      const data = await apiReadSheet(sc.spreadsheetId, sc.sheetName)
      sheetsTestResult = `ok:Успешно. Строк: ${(data.values || []).length}`
    } catch (err) { sheetsTestResult = `error:${err.message}` }
    sheetsTestLoading = false
  }

  // --- FTP functions ---
  function saveFtpConfig() {
    const fc = {
      protocol: ftpProtocol, host: ftpHost.trim(), port: ftpPort.trim(),
      user: ftpUser.trim(), password: ftpPassword.trim() || undefined,
      remotePath: ftpRemotePath.trim().replace(/\/$/, ''),
      baseUrl: ftpBaseUrl.trim().replace(/\/$/, ''),
    }
    saveSettings({ ...settings, ftpConfig: fc })
  }
  function saveMailingService() {
    saveSettings({ ...settings, mailingService: {
      label: mailingServiceLabel.trim(), url: mailingServiceUrl.trim(),
    }})
  }

  let feedRefreshLoading = false, feedRefreshResult = '', feedRefreshDiff = null

  function saveFeedUrl() {
    saveSettings({ ...settings, feedUrl: feedUrlDraft.trim() })
  }

  function saveRetailcrm() {
    const url = retailcrmUrlDraft.trim().replace(/\/$/, '')
    const apiKey = retailcrmApiKeyDraft.trim()
    saveSettings({ ...settings, retailcrmConfig: url && apiKey ? { url, apiKey } : null })
  }

  // --- TZ import ---
  function parseTzText(text) {
    const categoryPrefixRe = /^(игровое\s+кресло|игровой\s+компьютер|компьютер|кресло|стол|стул|мышь|гарнитура|клавиатура|монитор|коврик)\s+/i
    const priceRe = /([\d][\d\s]*\d)\s*[рpPРр]?\s*$/
    const discountRe = /^[-−]?\s*(\d+)\s*%/

    // Group lines into product blocks, each ending with a URL (or end of text)
    const lines = text.split('\n').map(l => l.trim())
    const blocks = []
    let cur = []
    for (const line of lines) {
      if (!line) continue
      if (/^https?:\/\//i.test(line)) {
        blocks.push({ lines: cur, url: line })
        cur = []
      } else {
        cur.push(line)
      }
    }
    if (cur.length) blocks.push({ lines: cur, url: '' })

    const products = []
    for (const { lines: blines, url } of blocks) {
      if (!blines.length) continue
      let nameStr = '', oldprice = '', price = '', discount = '', category = ''

      for (const line of blines) {
        // Discount-only line: "-20%" or "20%"
        const dm = line.match(discountRe)
        if (dm && !line.match(/[а-яёa-z]/i)) { discount = dm[1]; continue }

        // Price-only line: "21 590 Р" (no letters except price suffix)
        const purePrice = line.replace(/[рpPРр]/g, '').trim()
        if (/^[\d\s]+$/.test(purePrice) && purePrice.trim()) {
          const val = purePrice.replace(/\s/g, '')
          if (!price) price = val; else oldprice = price, price = val
          continue
        }

        // Name line with inline price: "Кресло BAD BUNNY Black-Grey 26 990 Р"
        const categoryMatch = line.match(/\(([^)]+)\)\s*$/)
        if (categoryMatch) category = categoryMatch[1]
        const withoutCat = categoryMatch ? line.slice(0, categoryMatch.index).trim() : line

        // Inline sale: "26 990 → 21 590 (-20%)"
        const saleMatch = withoutCat.match(/([\d][\d\s]*\d)\s*[рpPРр]?\s*[→>]\s*([\d][\d\s]*\d)\s*[рpPРр]?\s*[-−]?\(?(\d+)\s*%/)
        if (saleMatch) {
          oldprice = saleMatch[1].replace(/\s/g, '')
          price = saleMatch[2].replace(/\s/g, '')
          discount = saleMatch[3]
          nameStr = withoutCat.slice(0, saleMatch.index).trim()
          continue
        }

        // Inline: "Name 29 990 Р 19 990 Р 33%" — two prices + optional discount
        const twoPriceMatch = withoutCat.match(/([\d][\d\s]*\d)\s*[рpPРр₽]\.?\s+([\d][\d\s]*\d)\s*[рpPРр₽]\.?\s+[-−]?(\d+)\s*%/)
        if (twoPriceMatch) {
          oldprice = twoPriceMatch[1].replace(/\s/g, '')
          price = twoPriceMatch[2].replace(/\s/g, '')
          discount = twoPriceMatch[3]
          nameStr = withoutCat.slice(0, twoPriceMatch.index).trim()
          continue
        }

        // Inline: "Name 29 990 Р 19 990 Р" — two prices, no discount
        const twoPriceNoPctMatch = withoutCat.match(/([\d][\d\s]*\d)\s*[рpPРр₽]\.?\s+([\d][\d\s]*\d)\s*[рpPРр₽]\.?\s*$/)
        if (twoPriceNoPctMatch) {
          oldprice = twoPriceNoPctMatch[1].replace(/\s/g, '')
          price = twoPriceNoPctMatch[2].replace(/\s/g, '')
          nameStr = withoutCat.slice(0, twoPriceNoPctMatch.index).trim()
          continue
        }

        const pm = withoutCat.match(priceRe)
        if (pm) {
          const val = pm[1].replace(/\s/g, '')
          if (!nameStr) {
            nameStr = withoutCat.slice(0, pm.index).trim()
            if (!price) price = val; else { oldprice = price; price = val }
          } else {
            // nameStr already set — this is a sale price on a separate line
            oldprice = price; price = val
          }
        } else if (!nameStr && withoutCat) {
          nameStr = withoutCat
        }
      }

      if (!nameStr) continue
      const modelName = nameStr.replace(categoryPrefixRe, '').trim()
      const capsMatch = modelName.match(/\b([A-Z][A-Z0-9]*[-][A-Z0-9]+)\b/)
      const keyword = capsMatch ? capsMatch[1].trim() : modelName
      if (keyword) products.push({ keyword, nameStr: modelName, price, oldprice, discount, category, url })
    }
    return products
  }

  async function runTzImport() {
    tzModalLoading = true
    tzModalError = ''
    tzModalResults = []
    const parsed = parseTzText(tzModalText)
    if (!parsed.length) { tzModalError = 'Не удалось распознать товары'; tzModalLoading = false; return }
    const results = []
    for (const item of parsed) {
      try {
        const data = await apiRequest(`/project/${encodeURIComponent(currentProject)}/feed-suggest`, {
          method: 'POST', body: JSON.stringify({ productId: item.keyword, search: item.keyword, url: item.url || '', excludeIds: [] })
        })
        const match = (data.suggestions || [])[0] || null
        results.push({
          ...item,
          match,
          selectedId: match?.id || null,
          // Pre-fill editable fields, preferring TZ data over RetailCRM
          editId: match?.id || '',
          editName: item.nameStr || '',
          editCategory: item.category || '',
          editOldprice: item.oldprice || '',
          editPrice: item.price || '',
          editDiscount: item.discount || '',
          editUrl: item.url || match?.url || '',
        })
      } catch (e) {
        results.push({ ...item, match: null, selectedId: null,
          editId: '', editName: item.nameStr || '', editCategory: item.category || '',
          editOldprice: item.oldprice || '', editPrice: item.price || '',
          editDiscount: item.discount || '', editUrl: item.url || '',
        })
      }
    }
    tzModalResults = results
    tzModalLoading = false
  }

  function applyTzImport() {
    const block = assembledBlocks.find(b => b.id === tzModalBlockId)
    if (!block) return
    // Detect mixin name from existing content
    const mixinMatch = block.content.match(/\+(\w+)\s*\(/)
    const mixinName = mixinMatch ? mixinMatch[1] : 'products3inRow'
    const indent = '  '
    const lines = [`+${mixinName}({`]
    for (const r of tzModalResults) {
      if (!r.editId) continue
      const esc = s => String(s).replace(/'/g, "\\'")
      lines.push(`${indent}'${r.editId}': {`)
      lines.push(`${indent}${indent}imageSrc: '',`)
      if (r.editName)     lines.push(`${indent}${indent}name: '${esc(r.editName)}',`)
      if (r.editCategory) lines.push(`${indent}${indent}category: '${esc(r.editCategory)}',`)
      if (r.editOldprice) lines.push(`${indent}${indent}oldprice: ${r.editOldprice},`)
      if (r.editPrice)    lines.push(`${indent}${indent}price: ${r.editPrice},`)
      if (r.editDiscount) lines.push(`${indent}${indent}discount: '${r.editDiscount}',`)
      if (r.editUrl)      lines.push(`${indent}${indent}url: '${esc(r.editUrl)}',`)
      lines.push(`${indent}},`)
    }
    lines.push('})')
    updateBlockContent(tzModalBlockId, lines.join('\n'))
    tzModalBlockId = null
  }

  async function refreshFeed() {
    if (!currentProject || (!feedUrlDraft.trim() && !settings?.retailcrmConfig?.url)) return
    feedRefreshLoading = true
    feedRefreshResult = ''
    feedRefreshDiff = null
    try {
      const data = await apiFeedRefresh(currentProject)
      if (data.added !== undefined) {
        const parts = [`${data.count} товаров`]
        if (data.added > 0) parts.push(`+${data.added} новых`)
        if (data.removed > 0) parts.push(`−${data.removed} удалено`)
        if (data.added === 0 && data.removed === 0) parts.push('без изменений')
        feedRefreshResult = `ok:${parts.join(', ')}`
        feedRefreshDiff = data
      } else {
        feedRefreshResult = `ok:Загружено ${data.count} товаров`
      }
    } catch (err) {
      feedRefreshResult = `error:${err.message}`
    }
    feedRefreshLoading = false
  }

  async function testFtpConnection() {
    ftpTestLoading = true; ftpTestResult = ''
    saveFtpConfig()
    try {
      const result = await apiFtpTest(currentProject)
      ftpTestResult = `ok:${result.message || 'Подключено'}`
    } catch (err) { ftpTestResult = `error:${err.message}` }
    ftpTestLoading = false
  }
  function getLetterDateFolder() {
    if (!currentLetterDate) return ''
    return formatLetterDate(currentLetterDate).slice(0, 5)
  }
  async function openFtpGallery(block, field) {
    const folder = getLetterDateFolder()
    if (!folder) { alert('Сначала создай или открой письмо'); return }
    ftpGalleryTargetField = { block, field }
    ftpGalleryFolder = folder; ftpGalleryImages = []; ftpGalleryError = ''
    ftpUploadFileName = ''; ftpUploadFile = null; ftpUploadError = ''; ftpUploading = false
    showFtpGalleryModal = true
    await loadFtpGallery()
  }
  async function loadFtpGallery() {
    ftpGalleryLoading = true; ftpGalleryError = ''
    try {
      const result = await apiFtpList(currentProject, ftpGalleryFolder)
      ftpGalleryImages = result.files || []
    } catch (e) { ftpGalleryError = e.message }
    ftpGalleryLoading = false
  }
  function selectFtpImage(url) {
    if (ftpGalleryTargetField) {
      updateField(ftpGalleryTargetField.block, ftpGalleryTargetField.field, url)
    }
    showFtpGalleryModal = false
  }
  let ftpUploadStatus = ''
  async function uploadToFtp() {
    if (!ftpUploadFile || !ftpUploadFileName.trim()) return
    ftpUploading = true; ftpUploadError = ''; ftpUploadStatus = 'Читаю файл…'
    try {
      const dataUrl = await new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.onload = () => resolve(reader.result)
        reader.onerror = reject
        reader.readAsDataURL(ftpUploadFile)
      })
      ftpUploadStatus = 'Подключаюсь к серверу…'
      const result = await apiFtpUpload(currentProject, {
        imageData: dataUrl, fileName: ftpUploadFileName.trim(), folder: ftpGalleryFolder,
      })
      ftpUploadStatus = 'Загружено!'
      if (ftpGalleryTargetField) {
        updateField(ftpGalleryTargetField.block, ftpGalleryTargetField.field, result.url)
      }
      await loadFtpGallery()
      ftpUploadFileName = ''; ftpUploadFile = null
      setTimeout(() => { ftpUploadStatus = '' }, 5000)
    } catch (e) {
      // Попробуем достать steps из ответа
      let detail = e.message
      try {
        const body = JSON.parse(e.message.replace(/^[^{]*/, ''))
        if (body.steps) detail = body.details + '\nШаги: ' + body.steps.join(' → ')
      } catch {}
      ftpUploadError = detail
      ftpUploadStatus = ''
    }
    ftpUploading = false
  }
  async function handleFtpFileSelect(event) {
    const files = Array.from(event.target.files || [])
    if (!files.length) return
    ftpUploadError = ''; ftpUploading = true
    const errors = []
    for (const file of files) {
      if (file.size > 20 * 1024 * 1024) { errors.push(`${file.name}: макс. 20 МБ`); continue }
      ftpUploadStatus = `Загружаю ${file.name}…`
      try {
        const dataUrl = await new Promise((resolve, reject) => {
          const reader = new FileReader()
          reader.onload = () => resolve(reader.result)
          reader.onerror = reject
          reader.readAsDataURL(file)
        })
        const fileName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_')
        await apiFtpUpload(currentProject, { imageData: dataUrl, fileName, folder: ftpGalleryFolder })
      } catch (e) { errors.push(`${file.name}: ${e?.message || 'Ошибка'}`) }
    }
    ftpUploading = false
    ftpUploadStatus = errors.length ? '' : `Загружено ${files.length} файл(а/ов)!`
    ftpUploadError = errors.join('\n')
    event.target.value = ''
    await loadFtpGallery()
    if (!errors.length) setTimeout(() => { ftpUploadStatus = '' }, 4000)
  }

  function colToIndex(letter) { return ((letter || 'A').toUpperCase().charCodeAt(0) - 65) }
  function isUrl(str) { return /^https?:\/\//i.test(str) }

  function parsePlanDate(str) {
    const parts = String(str || '').trim().split(/[./]/)
    if (parts.length !== 3) return null
    const [d, m, y] = parts.map(Number)
    if (!d || !m || !y) return null
    return new Date(y, m - 1, d)
  }

  function formatPlanDate(date) {
    if (!date) return ''
    return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`
  }

  function planRowFingerprint(r) { return `${r.project}|${r.dateStr}|${r.subject}|${r.status}` }

  function savePlanCache(rows, at) {
    try {
      const serializable = rows.map(r => ({ ...r, date: r.date?.toISOString() }))
      localStorage.setItem('z51-plan-cache', JSON.stringify({ rows: serializable, at: at.toISOString() }))
    } catch {}
  }

  function loadPlanCache() {
    try {
      const raw = localStorage.getItem('z51-plan-cache')
      if (!raw) return null
      const data = JSON.parse(raw)
      const rows = (data.rows || []).map(r => ({ ...r, date: r.date ? new Date(r.date) : null }))
      return { rows, at: new Date(data.at) }
    } catch { return null }
  }

  function detectPlanChanges(oldRows, newRows) {
    const oldSet = new Set(oldRows.map(planRowFingerprint))
    const newSet = new Set(newRows.map(planRowFingerprint))
    const added = newRows.filter(r => !oldSet.has(planRowFingerprint(r)))
    const removed = oldRows.filter(r => !newSet.has(planRowFingerprint(r)))
    return { added, removed }
  }

  function showToast(message, type = 'info', duration = 5000) {
    const id = ++toastId
    toasts = [...toasts, { id, message, type }]
    setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, duration)
  }

  function sendPlanNotification(changes) {
    const lines = []
    for (const r of changes.added) lines.push(`+ ${r.project}: ${r.subject}`)
    for (const r of changes.removed) lines.push(`− ${r.project}: ${r.subject}`)
    if (lines.length === 0) return
    const text = lines.slice(0, 5).join('\n')
    showToast('План обновлён:\n' + text, 'plan')
    if (planNotificationsEnabled && Notification.permission === 'granted') {
      new Notification('ASPEKTER — План обновлён', { body: text, icon: '/favicon.ico' })
    }
  }

  async function enablePlanNotifications() {
    if (!('Notification' in window)) return
    if (Notification.permission === 'granted') { planNotificationsEnabled = true; return }
    if (Notification.permission === 'denied') return
    const result = await Notification.requestPermission()
    planNotificationsEnabled = result === 'granted'
  }

  async function fetchPlanRows() {
    const now = new Date()
    const cutPast = new Date(now.getTime() - 7 * 86400000)
    const cutFuture = new Date(now.getTime() + 60 * 86400000)
    const results = []
    const warnings = []
    for (const projName of projects) {
      let projSettings
      try { const d = await apiGetProject(projName); projSettings = d.settings || {} } catch { continue }
      const sc = projSettings.sheetsConfig
      if (!sc?.spreadsheetId || !sc?.sheetName || !sc?.columns) continue
      try {
        const data = await apiReadSheet(sc.spreadsheetId, sc.sheetName)
        const rows = data.values || []
        const hyperlinks = data.hyperlinks || []
        const headerRows = sc.headerRows ?? 1
        const headerRow = rows[headerRows - 1] || []
        const coreIndices = new Set([
          colToIndex(sc.columns.date),
          colToIndex(sc.columns.subject),
          colToIndex(sc.columns.preheader),
          colToIndex(sc.columns.status),
        ])
        const skipHeaderRe = /(id|hash|uid|guid|key|token)/i
        const junkValueRe = /^[0-9a-f]{8,}$/i
        const configuredExtra = sc.extraColumns || []
        const extraCols = []
        if (configuredExtra.length > 0) {
          for (const ce of configuredExtra) {
            if (!ce.col) continue
            const idx = colToIndex(ce.col)
            if (coreIndices.has(idx)) continue
            extraCols.push({ index: idx, label: ce.label || headerRow[idx]?.trim() || ce.col, visible: ce.visible !== false })
          }
        } else {
          for (let c = 0; c < headerRow.length; c++) {
            const hdr = headerRow[c]?.trim()
            if (!hdr || coreIndices.has(c) || skipHeaderRe.test(hdr)) continue
            extraCols.push({ index: c, label: hdr, visible: true })
          }
        }
        let found = 0
        for (let i = headerRows; i < rows.length; i++) {
          const row = rows[i]
          const rowLinks = hyperlinks[i] || []
          const dateStr = row[colToIndex(sc.columns.date)] || ''
          const subject = row[colToIndex(sc.columns.subject)] || ''
          const preheader = row[colToIndex(sc.columns.preheader)] || ''
          const status = row[colToIndex(sc.columns.status)] || ''
          if (!dateStr || !subject) continue
          const date = parsePlanDate(dateStr)
          if (!date || date < cutPast || date > cutFuture) continue
          if (sc.statusFilter?.length > 0 && !sc.statusFilter.includes(status)) continue
          const extra = []
          for (const col of extraCols) {
            if (col.visible === false) continue
            const val = (row[col.index] || '').trim()
            const link = (rowLinks[col.index] || '').trim()
            if (!val && !link) continue
            if (val && !link && (junkValueRe.test(val) || val.includes('@') || /^[0-9a-f]{8}-/i.test(val))) continue
            extra.push({ label: col.label, value: val, link })
          }
          results.push({ project: projName, date, dateStr, subject, preheader, status, rowIndex: i, extra })
          found++
        }
        if (found === 0) {
          const totalDataRows = rows.length - headerRows
          const sampleDate = rows[headerRows] ? rows[headerRows][colToIndex(sc.columns.date)] : '—'
          warnings.push(`${projName}: 0 строк в диапазоне −7/+60 дней из ${totalDataRows} всего. Пример даты в колонке ${sc.columns.date}: «${sampleDate}»`)
        }
      } catch (e) {
        warnings.push(`${projName}: ошибка чтения таблицы — ${e?.message || e}`)
      }
    }
    return { rows: results.sort((a, b) => a.date - b.date), warnings }
  }

  async function loadPlan(background = false) {
    if (!background) {
      const cache = loadPlanCache()
      if (cache && cache.rows.length > 0) {
        planRows = cache.rows
        planLoadedAt = cache.at
      }
      planLoading = true; planError = ''; planWarnings = []
    }
    try {
      const { rows, warnings } = await fetchPlanRows()
      if (!background && planRows.length > 0) {
        const changes = detectPlanChanges(planRows, rows)
        if (changes.added.length || changes.removed.length) sendPlanNotification(changes)
      }
      if (background && planRows.length > 0) {
        const changes = detectPlanChanges(planRows, rows)
        if (changes.added.length || changes.removed.length) sendPlanNotification(changes)
      }
      planRows = rows
      planWarnings = warnings
      planLoadedAt = new Date()
      savePlanCache(rows, planLoadedAt)
    } catch (e) {
      if (!background) planError = e?.message || String(e)
    }
    if (!background) planLoading = false
  }

  function startPlanPolling() {
    stopPlanPolling()
    planPollingTimer = setInterval(() => loadPlan(true), 3 * 60 * 1000)
  }

  function stopPlanPolling() {
    if (planPollingTimer) { clearInterval(planPollingTimer); planPollingTimer = null }
  }

  async function startAssemble(row) {
    if (row.project !== currentProject) await loadProject(row.project)
    // Check for existing letter with same subject and date
    const date = planDateToIso(row.date)
    const existing = letters.find(l => l.title === row.subject && l.date === date)
    if (existing) {
      await openLetter(existing)
      // Update assemblyInfo with fresh data from plan (status/extras may have changed)
      assemblyInfo = {
        subject: row.subject,
        preheader: row.preheader,
        status: row.status,
        dateStr: row.dateStr,
        project: row.project,
        extra: row.extra || [],
      }
      assemblyCardHidden = false
      return
    }
    assemblyInfo = {
      subject: row.subject,
      preheader: row.preheader,
      status: row.status,
      dateStr: row.dateStr,
      project: row.project,
      extra: row.extra || [],
    }
    assemblyCardHidden = false
    const id = createId()
    const title = row.subject
    const name = buildLetterName(title, date)
    const now = new Date().toISOString()
    const nextLetter = { id, name, title, date, createdAt: now, updatedAt: now, assemblyInfo }
    letters = [nextLetter, ...letters]
    currentLetterId = id; currentLetterName = name; currentLetterTitle = title; currentLetterDate = date
    assembledBlocks = []; rebuildOutput()
    await apiSaveLetters(currentProject, { list: letters, currentId: id })
    await apiSaveLetter(currentProject, { ...nextLetter, blocks: [] })
    setActivePage('builder')
  }

  function planRowId(row) { return `${row.project}::${row.rowIndex}` }

  async function setPlanRowStatus(row, newStatus) {
    if (newStatus === row.status) { planEditingRowId = null; return }
    if (!confirm(`Изменить статус «${row.subject}» на «${newStatus}»?`)) {
      planEditingRowId = null; return
    }
    const id = planRowId(row)
    planSavingRowId = id; planSaveError = ''
    try {
      const projData = await apiGetProject(row.project)
      const sc = projData.settings?.sheetsConfig
      if (!sc?.columns?.status) throw new Error('Колонка статуса не настроена')
      const sheetRow = row.rowIndex + 1
      await apiWriteSheetCell(sc.spreadsheetId, sc.sheetName, sc.columns.status, sheetRow, newStatus)
      planRows = planRows.map(r => planRowId(r) === id ? { ...r, status: newStatus } : r)
      planEditingRowId = null
    } catch (e) {
      planSaveError = e?.message || 'Ошибка записи'
    }
    planSavingRowId = null
  }

  async function updateAssemblyStatus(newStatus) {
    if (!assemblyInfo || newStatus === assemblyInfo.status) return
    const row = planRows.find(r => r.project === assemblyInfo.project && r.subject === assemblyInfo.subject && r.dateStr === assemblyInfo.dateStr)
    if (row) {
      try {
        const projData = await apiGetProject(row.project)
        const sc = projData.settings?.sheetsConfig
        if (sc?.columns?.status) {
          await apiWriteSheetCell(sc.spreadsheetId, sc.sheetName, sc.columns.status, row.rowIndex + 1, newStatus)
          planRows = planRows.map(r => planRowId(r) === planRowId(row) ? { ...r, status: newStatus } : r)
        }
      } catch {}
    }
    assemblyInfo = { ...assemblyInfo, status: newStatus }
    const s = (newStatus || '').toLowerCase()
    if (s.includes('отправлен') || s.includes('отправк') || s.includes('поставлен')) {
      statsStop(newStatus)
    }
  }

  function applyAutoTitles() {
    if (typeof document === 'undefined') return
    const elements = document.querySelectorAll('button, input, select, textarea')
    elements.forEach((el) => {
      if (el.getAttribute('title')) return
      let title = el.getAttribute('aria-label') || ''
      if (!title && el instanceof HTMLInputElement) {
        title = el.placeholder || ''
      }
      if (!title && el instanceof HTMLTextAreaElement) {
        title = el.placeholder || ''
      }
      if (!title && el instanceof HTMLSelectElement) {
        const selected = el.selectedOptions?.[0]
        title = selected?.textContent?.trim() || ''
      }
      if (!title && el instanceof HTMLButtonElement) {
        title = el.textContent?.trim() || ''
      }
      if (!title) {
        const label = el.closest('label')
        if (label) {
          title = label.textContent?.trim() || ''
        }
      }
      if (!title) return
      el.setAttribute('title', title)
    })
  }

  function setBodyTheme(value) {
    if (typeof document === 'undefined') return
    document.body.classList.toggle('theme-dark', value === 'dark')
    document.body.classList.toggle('theme-light', value === 'light')
  }

  function setAccentColor(value) {
    if (typeof document === 'undefined') return
    if (!value) return
    const accent = value.trim()
    if (!/^#([0-9a-fA-F]{6})$/.test(accent)) return
    const root = document.documentElement
    const contrast = getContrastColor(accent)
    root.style.setProperty('--project-accent', accent)
    root.style.setProperty('--project-accent-contrast', contrast)
    document.body.style.setProperty('--project-accent', accent)
    document.body.style.setProperty('--project-accent-contrast', contrast)
  }

  function getContrastColor(hex) {
    const r = parseInt(hex.slice(1, 3), 16) / 255
    const g = parseInt(hex.slice(3, 5), 16) / 255
    const b = parseInt(hex.slice(5, 7), 16) / 255
    const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
    return luminance > 0.6 ? '#0b0f12' : '#ffffff'
  }

  function getSettingsBlockName() {
    return allBlocks[settingsBlockIndex]?.name ?? ''
  }

  function updateSettingsTemplate(name, value) {
    pushHistory()
    const trimmed = value.trim()
    if (trimmed === '') {
      const current = getBlockSetting(name)
      const nextFields = current.fields
      const next = { ...settings, blocks: { ...settings.blocks, [name]: { ...current } } }
      delete next.blocks[name].template
      if (!nextFields) delete next.blocks[name].fields
      if (Object.keys(next.blocks[name]).length === 0) delete next.blocks[name]
      saveSettings(next)
    } else {
      updateSettingsBlock(name, { template: value })
    }
    const effective = getEffectiveTemplate(name)
    assembledBlocks = assembledBlocks.map((block) =>
      block.name === name
        ? {
            ...block,
            content: effective,
            schema: buildBaseSchema(effective, name, settings?.mixinRules),
          }
        : block
    )
    rebuildOutput()
    saveDraft()
  }

  function updateSettingsGlobalSpacing(value) {
    const spacing = Number(value)
    if (Number.isNaN(spacing)) return
    pushHistory()
    saveSettings({ ...settings, globalSpacing: spacing })
    rebuildOutput()
  }

  function updateSettingsBlockSpacing(name, value) {
    const spacing = Number(value)
    if (Number.isNaN(spacing)) return
    pushHistory()
    updateSettingsBlock(name, { spacing })
    rebuildOutput()
  }

  function updateSettingsField(name, signature, patch) {
    pushHistory()
    const current = getBlockSetting(name).fields ?? {}
    updateSettingsBlock(name, {
      fields: {
        ...current,
        [signature]: { ...current[signature], ...patch },
      },
    })
  }

  function toggleTemplateCard(name) {
    if (templatesExpanded.has(name)) templatesExpanded.delete(name)
    else templatesExpanded.add(name)
    templatesExpanded = templatesExpanded
  }

  function templateRows(name) {
    const lines = getEffectiveTemplate(name).split('\n').length
    return Math.max(6, Math.min(30, lines + 1))
  }

  function duplicateBlockAsCustom(sourceName) {
    const allNames = new Set([
      ...availableBlocks.map(b => b.name),
      ...(settings.customBlocks || []).map(b => b.name),
    ])
    let defaultName = `${sourceName} копия`
    let n = 2
    while (allNames.has(defaultName)) {
      defaultName = `${sourceName} копия ${n}`
      n++
    }
    const name = prompt('Название нового блока:', defaultName)
    if (!name?.trim()) return
    const trimmedName = name.trim()
    if (allNames.has(trimmedName)) { alert('Блок с таким именем уже существует'); return }
    const newBlock = { id: createId(), name: trimmedName, content: getEffectiveTemplate(sourceName) }
    pushHistory()
    saveSettings({ ...settings, customBlocks: [...(settings.customBlocks || []), newBlock] })
  }

  function renameBlock(block) {
    const newName = prompt('Новое название:', block.name)
    if (!newName?.trim() || newName.trim() === block.name) return
    const trimmedName = newName.trim()
    const allNames = new Set(allBlocks.map(b => b.name))
    allNames.delete(block.name)
    if (allNames.has(trimmedName)) { alert('Блок с таким именем уже существует'); return }
    if (block.isCustom) {
      updateCustomBlock(block.customId, { name: trimmedName })
      return
    }
    // Built-in block rename
    pushHistory()
    const oldName = block.name
    availableBlocks = availableBlocks.map(b =>
      b.name === oldName ? { ...b, name: trimmedName } : b
    )
    assembledBlocks = assembledBlocks.map(ab =>
      ab.name === oldName ? { ...ab, name: trimmedName } : ab
    )
    const nextSettings = { ...settings }
    nextSettings.blocks = { ...(nextSettings.blocks || {}) }
    if (nextSettings.blocks[oldName]) {
      nextSettings.blocks[trimmedName] = nextSettings.blocks[oldName]
      delete nextSettings.blocks[oldName]
    }
    if (Array.isArray(nextSettings.quickBlocks)) {
      nextSettings.quickBlocks = nextSettings.quickBlocks.map(n => n === oldName ? trimmedName : n)
    }
    saveSettings(nextSettings)
    // Save updated block text to server
    const blockText = availableBlocks.map(b => `//${b.name}\n${b.content}`).join('\n\n') + '\n'
    apiSaveBlock(currentProject, { sourceName, blockText })
    rebuildOutput()
    saveDraft()
  }

  function toggleAllBlocksSpacing(enabled) {
    pushHistory()
    const nextBlocks = { ...(settings.blocks || {}) }
    for (const block of allBlocks) {
      if (enabled) {
        if (nextBlocks[block.name]) {
          delete nextBlocks[block.name].spacingEnabled
          if (Object.keys(nextBlocks[block.name]).length === 0) delete nextBlocks[block.name]
        }
      } else {
        nextBlocks[block.name] = { ...(nextBlocks[block.name] || {}), spacingEnabled: false }
      }
    }
    saveSettings({ ...settings, blocks: nextBlocks })
    rebuildOutput()
  }

  function buildBlocksPugText() {
    return allBlocks.map(b => `//${b.name}\n${getEffectiveTemplate(b.name)}`).join('\n\n') + '\n'
  }

  async function exportBlocksPug() {
    const text = buildBlocksPugText()
    try {
      await apiRequest(`/project/${encodeURIComponent(currentProject)}/block-custom`, {
        method: 'PUT',
        body: JSON.stringify({ content: text }),
      })
      alert('Сохранено в block-custom.pug')
    } catch (err) {
      alert('Ошибка: ' + err.message)
    }
  }

  function downloadBlocksPug() {
    downloadFile(buildBlocksPugText(), `${currentProject || 'blocks'}.pug`)
  }

  function importBlocksPug() {
    const input = document.createElement('input')
    input.type = 'file'
    input.accept = '.pug'
    input.onchange = async () => {
      const file = input.files[0]
      if (!file) return
      const text = await file.text()
      const importedBlocks = parseBlocks(text)
      if (!importedBlocks.length) { alert('Файл не содержит блоков'); return }
      pushHistory()
      const availableNames = new Set(availableBlocks.map(b => b.name))
      const customNames = new Set((settings.customBlocks || []).map(b => b.name))
      const nextSettings = { ...settings }
      nextSettings.blocks = { ...(nextSettings.blocks || {}) }
      const nextCustom = [...(nextSettings.customBlocks || [])]
      let updated = 0
      for (const block of importedBlocks) {
        if (availableNames.has(block.name)) {
          nextSettings.blocks[block.name] = {
            ...(nextSettings.blocks[block.name] || {}),
            template: block.content,
          }
          updated++
        } else if (customNames.has(block.name)) {
          const idx = nextCustom.findIndex(cb => cb.name === block.name)
          if (idx >= 0) { nextCustom[idx] = { ...nextCustom[idx], content: block.content }; updated++ }
        } else {
          nextCustom.push({ id: createId(), name: block.name, content: block.content })
          updated++
        }
      }
      nextSettings.customBlocks = nextCustom
      saveSettings(nextSettings)
      alert(`Импортировано ${updated} из ${importedBlocks.length} блоков`)
    }
    input.click()
  }

  function insertLink(block, field) {
    const key = fieldKey(block, field)
    const input = textFieldRefs[key]
    const current = getFieldValue(block, field)
    const url = (linkDrafts[key] ?? '').trim()
    if (!url) return
    const tpl = settings.linkTemplate?.trim()
    const template = (text) => {
      if (!tpl) return `<a href="${url}" target="_blank">${text || 'Ссылка'}</a>`
      let result = tpl
      // Insert {url} if template has empty href=""
      if (!result.includes('{url}') && result.includes('href=""')) {
        result = result.replace('href=""', `href="{url}"`)
      }
      // Insert {text} if template has no placeholder
      if (!result.includes('{text}')) {
        result = result.replace(/>([^<]*)<\/a>/, '>{text}</a>')
      }
      return result.replace(/\{url\}/g, url).replace(/\{text\}/g, text || 'Ссылка')
    }
    if (!input) {
      updateField(block, field, `${current}${template('')}`)
      return
    }
    const start = input.selectionStart ?? current.length
    const end = input.selectionEnd ?? current.length
    const before = current.slice(0, start)
    const middle = current.slice(start, end)
    const after = current.slice(end)
    updateField(block, field, `${before}${template(middle)}${after}`)
  }

  function wrapCdata(text) {
    const safe = text.replace(/]]>/g, ']]]]><![CDATA[>')
    return `<![CDATA[${safe}]]>`
  }

  async function applyTypograf(block, field) {
    if (field.type !== 'text' && field.type !== 'list-item') return
    const key = fieldKey(block, field)
    typografStatus = { ...typografStatus, [key]: { loading: true, error: '' } }
    try {
      const soap = `<?xml version="1.0" encoding="utf-8"?>\n` +
        `<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ` +
        `xmlns:xsd="http://www.w3.org/2001/XMLSchema" ` +
        `xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">` +
        `<soap:Body>` +
        `<ProcessText xmlns="http://typograf.artlebedev.ru/webservices/">` +
        `<text>${wrapCdata(getFieldValue(block, field))}</text>` +
        `</ProcessText>` +
        `</soap:Body>` +
        `</soap:Envelope>`
      const res = await fetch('/typograf/webservices/typograf.asmx', {
        method: 'POST',
        headers: {
          'Content-Type': 'text/xml; charset=utf-8',
          SOAPAction: 'http://typograf.artlebedev.ru/webservices/ProcessText',
        },
        body: soap,
      })
      if (!res.ok) throw new Error('Сервис не ответил')
      const xml = await res.text()
      const doc = new DOMParser().parseFromString(xml, 'text/xml')
      const resultNode = doc.getElementsByTagName('ProcessTextResult')[0]
      if (!resultNode) throw new Error('Некорректный ответ')
      const result = resultNode.textContent ?? ''
      updateField(block, field, sanitizeTypograf(result))
      applyNowrapSpaces(block, field)
      typografStatus = { ...typografStatus, [key]: { loading: false, error: '' } }
    } catch (err) {
      typografStatus = {
        ...typografStatus,
        [key]: { loading: false, error: err.message || 'Ошибка типографа' },
      }
    }
  }

  function applyNowrapSpaces(block, field) {
    if (field.type !== 'text' && field.type !== 'list-item') return
    const text = getFieldValue(block, field)
    const result = text.replace(/(?<![a-zA-Zа-яА-ЯёЁ])([a-zA-Zа-яА-ЯёЁ]{1,3}) /gu, '$1&nbsp;')
    if (result !== text) {
      updateField(block, field, result)
    }
  }

  function sanitizeTypograf(text) {
    let cleaned = text.replace(/\r\n/g, '\n')
    cleaned = cleaned.replace(/<br\s*\/?>/gi, '')
    cleaned = cleaned.replace(/<\/p>\s*<p>/gi, '\n')
    cleaned = cleaned.replace(/^<p>/i, '')
    cleaned = cleaned.replace(/<\/p>$/i, '')
    cleaned = cleaned.replace(/  +/g, ' ')
    return cleaned.trim()
  }

  // --- Авто-переносы строк ---

  const NO_HANG_WORDS = new Set([
    // предлоги
    'в', 'на', 'за', 'из', 'от', 'до', 'по', 'об', 'при', 'для', 'без', 'под', 'над', 'про',
    'перед', 'через', 'между', 'после', 'около', 'кроме', 'вместо', 'среди', 'ради',
    // союзы
    'и', 'а', 'но', 'да', 'или', 'что', 'как', 'если', 'когда', 'чтобы', 'хотя', 'пока', 'ведь', 'либо',
    // частицы
    'не', 'ни', 'бы', 'же', 'ли', 'вот', 'вон', 'даже', 'лишь', 'ещё', 'уже',
    // местоимения
    'я', 'ты', 'он', 'она', 'они', 'мы', 'вы', 'это', 'кто', 'что',
    'все', 'всё', 'его', 'вас', 'нас', 'нам', 'вам', 'ему', 'ней', 'них',
  ])

  function shouldNotHang(word) {
    const clean = word.replace(/&\w+;/g, '').toLowerCase()
    return clean.length < 3 || NO_HANG_WORDS.has(clean)
  }

  function getFieldContainerWidth(block, field) {
    const lines = normalizeNewlines(block.content).split('\n')
    let width = 560
    for (let i = 0; i <= field.lineIndex && i < lines.length; i++) {
      const line = lines[i]
      // +defaultTable('560') or +defaultTable("173")
      const m1 = line.match(/\+defaultTable\(['"](\d+)['"]\)/)
      if (m1) width = parseInt(m1[1])
      // td(width="268") — column width on td element (skip small icon tds ≤100)
      const m2 = line.match(/td\([^)]*width\s*=\s*['"](\d+)['"]/)
      if (m2) {
        const tdW = parseInt(m2[1])
        if (tdW > 100) width = tdW
      }
    }
    return width
  }

  function getFieldFont(block, field) {
    const line = (normalizeNewlines(block.content).split('\n')[field.lineIndex]) || ''
    if (/header__main/.test(line)) return 'bold 24px Verdana'
    if (/header__h1/.test(line)) return 'bold 19px Verdana'
    if (/header__h2/.test(line)) return 'bold 16px Verdana'
    return '16px Verdana'
  }

  function tokenizeText(text) {
    // Split text into tokens: words, spaces, &entities;, <tags>
    const re = /(<[^>]+>)|(&\w+;)|(\s+)|([^\s<&]+)/g
    const tokens = []
    let m
    while ((m = re.exec(text)) !== null) {
      if (m[1]) tokens.push({ type: 'tag', raw: m[1] })
      else if (m[2]) tokens.push({ type: 'entity', raw: m[2] })
      else if (m[3]) tokens.push({ type: 'space', raw: m[3] })
      else if (m[4]) tokens.push({ type: 'word', raw: m[4] })
    }
    return tokens
  }

  function applyAutoBreaks(block, field) {
    if (field.type !== 'text' && field.type !== 'list-item') return
    const text = getFieldValue(block, field)
    const containerWidth = getFieldContainerWidth(block, field)
    const font = getFieldFont(block, field)

    const canvas = document.createElement('canvas')
    const ctx = canvas.getContext('2d')
    ctx.font = font
    const spaceWidth = ctx.measureText(' ').width

    // First remove any existing auto-inserted <br> (clean slate), restore spaces
    const cleanText = text.replace(/<br>/g, ' ').replace(/  +/g, ' ')

    // Split by manual line separators that should be preserved (none after clean)
    // Process the whole text as one segment
    const tokens = tokenizeText(cleanText)

    let currentWidth = 0
    let lastSpaceIdx = -1       // token index of last space (break opportunity)
    let lastWordBeforeSpace = '' // the word token before that space
    let lastWordIdxBeforeSpace = -1 // token index of the word before that space
    let result = []

    for (let i = 0; i < tokens.length; i++) {
      const tok = tokens[i]

      if (tok.type === 'tag') {
        result.push(tok.raw)
        continue
      }

      if (tok.type === 'space') {
        lastSpaceIdx = result.length
        // Find the last word before this space
        for (let j = i - 1; j >= 0; j--) {
          if (tokens[j].type === 'word') {
            lastWordBeforeSpace = tokens[j].raw
            lastWordIdxBeforeSpace = j
            break
          }
        }
        currentWidth += spaceWidth
        result.push(tok.raw)
        continue
      }

      // entity (&nbsp;) — measure as space width; treat as break opportunity
      // (clients that ignore &nbsp; will break here, so we must check for hanging words)
      if (tok.type === 'entity') {
        lastSpaceIdx = result.length
        for (let j = i - 1; j >= 0; j--) {
          if (tokens[j].type === 'word') {
            lastWordBeforeSpace = tokens[j].raw
            lastWordIdxBeforeSpace = j
            break
          }
        }
        currentWidth += spaceWidth
        result.push(tok.raw)
        continue
      }

      // word — measure
      const wordWidth = ctx.measureText(tok.raw).width
      if (currentWidth + wordWidth > containerWidth && lastSpaceIdx !== -1) {
        // Line overflow — insert break at last space
        if (shouldNotHang(lastWordBeforeSpace)) {
          // Find position of the word before space and insert <br> before it
          // We need to find where lastWordBeforeSpace appears in result before lastSpaceIdx
          let insertPos = -1
          for (let r = lastSpaceIdx - 1; r >= 0; r--) {
            if (result[r] === lastWordBeforeSpace) { insertPos = r; break }
          }
          // Also find the space before that word
          if (insertPos > 0 && result[insertPos - 1].trim() === '') {
            result.splice(insertPos - 1, 1, '<br>')
          } else if (insertPos >= 0) {
            result.splice(insertPos, 0, '<br>')
          } else {
            result.splice(lastSpaceIdx, 1, '<br>')
          }
        } else {
          // Replace space with <br>
          result.splice(lastSpaceIdx, 1, '<br>')
        }
        // Recalculate width from break point
        // Find the break position and measure everything after it
        const breakPos = result.lastIndexOf('<br>')
        currentWidth = 0
        for (let r = breakPos + 1; r < result.length; r++) {
          const s = result[r]
          if (s.startsWith('<')) continue // tag
          if (s === '&nbsp;' || s.trim() === '') currentWidth += spaceWidth
          else currentWidth += ctx.measureText(s).width
        }
        currentWidth += wordWidth
        lastSpaceIdx = -1
        lastWordBeforeSpace = ''
      } else {
        currentWidth += wordWidth
      }
      result.push(tok.raw)
    }

    updateField(block, field, result.join('').replace(/  +/g, ' '))
  }

  function handleCopy() {
    if (!outputPug.trim()) return
    navigator.clipboard?.writeText(outputPug)
  }

  function handleCopyHtml() {
    const src = rawPreviewHtml || previewHtml
    if (!src.trim()) return
    navigator.clipboard?.writeText(normalizeHtmlForCopy(src))
    statsMarkHtmlCopied()
  }

  function handleCopyHtmlRaw() {
    const src = rawPreviewHtml || previewHtml
    if (!src.trim()) return
    navigator.clipboard?.writeText(src)
    statsMarkHtmlCopied()
  }

  function downloadPug() {
    downloadFile(outputPug, 'assembled.pug')
  }

  function downloadHtml() {
    const src = rawPreviewHtml || previewHtml
    if (!src.trim()) return
    downloadFile(src, 'index.html', 'text/html;charset=utf-8')
  }

  function getEmailProjectSlug() {
    const fromSettings = String(settings.emailGenProject || '').trim()
    if (fromSettings) return fromSettings
    return String(currentProject || '')
      .trim()
      .toLowerCase()
      .replace(/\s+/g, '-')
  }


  async function renderEmailPreview(noCache = false) {
    if (!outputPug.trim()) {
      previewError = 'Сначала добавь блоки в сборку'
      return
    }
    const projectSlug = getEmailProjectSlug()
    if (!projectSlug) {
      previewError = 'Укажи папку проекта в email-gen в настройках'
      return
    }
    previewLoading = true
    previewError = ''
    try {
      const data = await apiRenderEmail(currentProject, { projectSlug, pug: outputPug, noCache })
      rawPreviewHtml = data.html || ''
      previewHtml = data.previewHtml || rawPreviewHtml
      unavailableProducts = data.unavailableProducts || []
      previewGeneratedAt = data.generatedAt || ''
      previewSourcePug = outputPug
      spellerHtml = ''
      spellErrors = []
      if (spellCheckActive) runSpellCheck()
      previewFlash = true
      setTimeout(() => { previewFlash = false }, 1500)
    } catch (error) {
      previewError = error?.message || 'Не удалось сгенерировать HTML'
    } finally {
      previewLoading = false
    }
  }

  function collectAllProductIds() {
    const ids = new Set()
    for (const block of assembledBlocks) {
      const schema = ensureSchema(block)
      const fields = applySchemaSettings(schema, block.name)
      for (const field of fields) {
        if (field.type === 'mixin-ids') {
          const lines = normalizeNewlines(block.content).split('\n')
          const line = lines[field.lineIndex] ?? ''
          const args = parseQuotedArgs(line)
          const val = args[field.argIndex ?? 0]?.value || ''
          val.split(',').map(s => s.trim()).filter(Boolean).forEach(id => ids.add(id))
        }
      }
    }
    return [...ids]
  }

  async function loadSuggestions(productId, search = '') {
    suggestForProduct = productId
    suggestLoading = true
    suggestResults = []
    try {
      const excludeIds = collectAllProductIds()
      const data = await apiFeedSuggest(currentProject, productId, excludeIds, search)
      suggestResults = data.suggestions || []
    } catch {
      suggestResults = []
    } finally {
      suggestLoading = false
    }
  }

  function applySuggestion(oldId, newId) {
    for (const block of assembledBlocks) {
      const schema = ensureSchema(block)
      const fields = applySchemaSettings(schema, block.name)
      for (const field of fields) {
        if (field.type === 'mixin-ids') {
          const lines = normalizeNewlines(block.content).split('\n')
          const line = lines[field.lineIndex] ?? ''
          const args = parseQuotedArgs(line)
          const val = args[field.argIndex ?? 0]?.value || ''
          const ids = val.split(',').map(s => s.trim())
          const idx = ids.indexOf(String(oldId))
          if (idx !== -1) {
            ids[idx] = String(newId)
            updateField(block, field, ids.join(','))
          }
        }
      }
    }
    suggestForProduct = null
    suggestResults = []
  }

  function extractTextFromBlocks() {
    const parts = []
    for (const block of assembledBlocks) {
      if (!block.content) continue
      const schema = buildBaseSchema(block.content, block.name, settings?.mixinRules)
      const lines = normalizeNewlines(block.content).split('\n')
      for (const field of schema) {
        if (field.type === 'text' || field.type === 'list-item') {
          const line = lines[field.lineIndex] ?? ''
          const meta = splitTextField(line)
          if (meta && meta.value.trim()) parts.push(meta.value)
        } else if (field.type === 'mixin-text') {
          const line = lines[field.lineIndex] ?? ''
          const args = parseQuotedArgs(line)
          const val = args[field.argIndex ?? 0]
          if (val && val.value && val.value.trim()) parts.push(val.value)
        }
      }
    }
    const raw = parts.join('\n')
    const doc = new DOMParser().parseFromString(raw, 'text/html')
    return (doc.body?.textContent || '')
      .replace(/\u00a0/g, ' ')
      .replace(/[«»„\u201C\u201D\u2018\u2019]/g, ' ')
      .replace(/\s+/g, ' ')
      .trim()
  }

  async function runSpellCheck() {
    if (!previewHtml) return
    spellLoading = true
    spellErrors = []
    spellerHtml = ''
    try {
      const text = extractTextFromHtml(previewHtml).slice(0, 9500)
      if (!text.trim()) return
      console.log('[spell] text:', text)
      const params = new URLSearchParams({ text, lang: 'ru,en', options: '10' })
      const res = await fetch('/speller/services/spellservice.json/checkText', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: params,
      })
      if (res.ok) {
        const errors = await res.json()
        console.log('[spell] errors:', errors)
        spellErrors = errors || []
        spellerHtml = injectSpellMarks(previewHtml, spellErrors)
      }
    } catch (e) {
      console.error('[spell] error:', e)
    } finally {
      spellLoading = false
    }
  }

  async function toggleSpellCheck() {
    spellCheckActive = !spellCheckActive
    if (spellCheckActive && previewHtml && !spellerHtml) {
      await runSpellCheck()
    }
  }

  function extractLinksFromHtml(html) {
    const parser = new DOMParser()
    const doc = parser.parseFromString(html, 'text/html')
    const links = new Set()
    doc.querySelectorAll('a[href]').forEach(a => {
      const href = a.getAttribute('href')
      if (href && /^https?:\/\//i.test(href)) links.add(href)
    })
    doc.querySelectorAll('img[src]').forEach(img => {
      const src = img.getAttribute('src')
      if (src && /^https?:\/\//i.test(src)) links.add(src)
    })
    return [...links]
  }

  async function runLinkCheck() {
    if (!previewHtml) return
    linkCheckLoading = true
    linkCheckResults = []
    try {
      const urls = extractLinksFromHtml(previewHtml)
      if (!urls.length) { linkCheckResults = []; return }
      const data = await apiCheckLinks(urls)
      linkCheckResults = data.results || []
    } catch (e) {
      console.error('[linkcheck]', e)
    } finally {
      linkCheckLoading = false
    }
  }

  function toggleLinkCheck() {
    linkCheckActive = !linkCheckActive
    if (linkCheckActive && previewHtml) runLinkCheck()
  }

  // ---- Auto-assemble ----
  $: hasProductMixins = (settings?.mixinRules || []).some(r => r.type === 'mixin-ids')
  $: hasFeed = !!(settings?.feedUrl)

  async function runAutoAssemble() {
    if (!currentProject) return
    autoAssembleLoading = true
    autoAssembleError = ''
    autoAssembleBlocks = []
    try {
      const priceMin = autoAssemblePricePreset === 'custom' ? parseFloat(autoAssemblePriceMin) || 0
        : autoAssemblePricePreset === '700-3000' ? 700
        : autoAssemblePricePreset === '3000+' ? 3000
        : 0
      const priceMax = autoAssemblePricePreset === 'custom' ? parseFloat(autoAssemblePriceMax) || 0
        : autoAssemblePricePreset === '700-3000' ? 3000
        : 0
      const excludeMaterials = []
      if (autoAssembleExcludeGold) excludeMaterials.push('gold')
      if (autoAssembleExcludeSilver) excludeMaterials.push('silver')
      if (autoAssembleExcludePlatinum) excludeMaterials.push('platinum')
      const layout = autoAssembleLayout.trim().split(/[\s,]+/).map(Number).filter(n => n >= 1 && n <= 3)
      if (!layout.length) { autoAssembleError = 'Укажите схему (напр. 3 1 3 3)'; autoAssembleLoading = false; return }
      const target = layout.reduce((s, n) => s + n, 0)
      const data = await apiAutoAssemble(currentProject, autoAssembleType, target, autoAssembleProductType, priceMin, priceMax, excludeMaterials, layout)
      autoAssembleBlocks = data.blocks || []
      if (!autoAssembleBlocks.length) autoAssembleError = 'Не найдено подходящих товаров'
    } catch (e) {
      autoAssembleError = e.message || 'Ошибка'
    } finally {
      autoAssembleLoading = false
    }
  }

  async function regenerateSlot(index) {
    if (!currentProject || !autoAssembleBlocks[index]) return
    const block = autoAssembleBlocks[index]
    const slotSize = block.ids.length
    // Collect IDs from all OTHER blocks to exclude
    const excludeIds = autoAssembleBlocks
      .filter((_, i) => i !== index)
      .flatMap(b => b.ids)
    try {
      autoAssembleBlocks[index] = { ...block, _loading: true }
      autoAssembleBlocks = [...autoAssembleBlocks]
      const priceMin = autoAssemblePricePreset === 'custom' ? parseFloat(autoAssemblePriceMin) || 0
        : autoAssemblePricePreset === '700-3000' ? 700
        : autoAssemblePricePreset === '3000+' ? 3000
        : 0
      const priceMax = autoAssemblePricePreset === 'custom' ? parseFloat(autoAssemblePriceMax) || 0
        : autoAssemblePricePreset === '700-3000' ? 3000
        : 0
      const excludeMaterials = []
      if (autoAssembleExcludeGold) excludeMaterials.push('gold')
      if (autoAssembleExcludeSilver) excludeMaterials.push('silver')
      if (autoAssembleExcludePlatinum) excludeMaterials.push('platinum')
      const data = await apiAutoAssemble(currentProject, autoAssembleType, slotSize, autoAssembleProductType, priceMin, priceMax, excludeMaterials, [slotSize], excludeIds)
      const newBlocks = data.blocks || []
      if (newBlocks.length > 0) {
        autoAssembleBlocks[index] = newBlocks[0]
      } else {
        autoAssembleBlocks[index] = block
      }
    } catch (e) {
      autoAssembleBlocks[index] = block
      console.error('[regenerateSlot]', e)
    }
    autoAssembleBlocks = [...autoAssembleBlocks]
  }

  async function replaceCoin(blockIndex, productIndex) {
    const block = autoAssembleBlocks[blockIndex]
    if (!block || !currentProject) return
    const clickedId = block.ids[productIndex]
    // Anchor = other coins in same block
    const anchorIds = block.ids.filter((_, i) => i !== productIndex)
    // Exclude all IDs from all blocks
    const allIds = autoAssembleBlocks.flatMap(b => b.ids)
    try {
      autoAssembleBlocks[blockIndex].products[productIndex]._loading = true
      autoAssembleBlocks = [...autoAssembleBlocks]
      const priceMin = autoAssemblePricePreset === 'custom' ? parseFloat(autoAssemblePriceMin) || 0
        : autoAssemblePricePreset === '700-3000' ? 700 : autoAssemblePricePreset === '3000+' ? 3000 : 0
      const priceMax = autoAssemblePricePreset === 'custom' ? parseFloat(autoAssemblePriceMax) || 0
        : autoAssemblePricePreset === '700-3000' ? 3000 : 0
      const excludeMaterials = []
      if (autoAssembleExcludeGold) excludeMaterials.push('gold')
      if (autoAssembleExcludeSilver) excludeMaterials.push('silver')
      if (autoAssembleExcludePlatinum) excludeMaterials.push('platinum')
      const data = await apiAutoAssemble(currentProject, autoAssembleType, 1, autoAssembleProductType, priceMin, priceMax, excludeMaterials, null, allIds, anchorIds)
      if (data.replacement) {
        const r = data.replacement
        block.ids[productIndex] = r.id
        block.products[productIndex] = r
      }
    } catch (e) {
      console.error('[replaceCoin]', e)
    }
    autoAssembleBlocks = [...autoAssembleBlocks]
  }

  function applyAutoAssemble() {
    if (!autoAssembleBlocks.length || !requireLetter()) return
    pushHistory()

    // Find existing product blocks (blocks that have mixin-ids fields in schema)
    const isProductBlock = (block) =>
      block.schema?.some(f => f.type === 'mixin-ids')

    // Group product block indices into consecutive runs
    const allProductIndices = assembledBlocks
      .map((block, idx) => isProductBlock(block) ? idx : -1)
      .filter(idx => idx >= 0)

    // Build runs of consecutive indices
    const runs = []
    for (const idx of allProductIndices) {
      if (runs.length && runs[runs.length - 1].at(-1) === idx - 1) {
        runs[runs.length - 1].push(idx)
      } else {
        runs.push([idx])
      }
    }

    // Only replace runs of 2+ blocks (isolated single blocks are "permanent", e.g. promo)
    // If no such run exists, fall back to all runs
    let targetRuns = runs.filter(r => r.length >= 2)
    if (!targetRuns.length) targetRuns = runs

    // Collect indices to replace (from the target runs, in order)
    const replaceIndices = targetRuns.flat()

    let newAssembled = [...assembledBlocks]
    let resultIdx = 0

    // Replace existing product blocks in order
    for (const blockIdx of replaceIndices) {
      if (resultIdx >= autoAssembleBlocks.length) break
      const b = autoAssembleBlocks[resultIdx++]
      const content = `+${b.mixin}("${b.ids.join(',')}")`
      newAssembled[blockIdx] = {
        ...newAssembled[blockIdx],
        name: b.mixin,
        content,
        schema: buildBaseSchema(content, b.mixin, settings?.mixinRules),
      }
    }

    // If auto-assembly has more blocks than existing slots, insert after last replaced block
    if (resultIdx < autoAssembleBlocks.length) {
      const insertAfter = replaceIndices.length > 0
        ? replaceIndices[replaceIndices.length - 1]
        : newAssembled.length - 1
      const toInsert = []
      for (; resultIdx < autoAssembleBlocks.length; resultIdx++) {
        const b = autoAssembleBlocks[resultIdx]
        const content = `+${b.mixin}("${b.ids.join(',')}")`
        toInsert.push({
          id: createId(),
          name: b.mixin,
          content,
          collapsed: false,
          schema: buildBaseSchema(content, b.mixin, settings?.mixinRules),
          addSpacing: true,
          spacing: undefined,
        })
      }
      newAssembled.splice(insertAfter + 1, 0, ...toInsert)
    }

    // If existing slots are more than auto-assembly blocks, remove leftover replaced blocks
    if (replaceIndices.length > autoAssembleBlocks.length) {
      const surplus = replaceIndices.slice(autoAssembleBlocks.length)
      // Remove in reverse order to preserve indices
      for (const idx of [...surplus].reverse()) {
        newAssembled.splice(idx, 1)
      }
    }

    assembledBlocks = newAssembled
    rebuildOutput()
    saveDraft()
    autoAssembleOpen = false
    autoAssembleBlocks = []
  }

  async function setOutputPanelMode(mode) {
    outputPanelMode = mode
    if ((mode === 'preview' || mode === 'html') && (!previewHtml || previewStale)) {
      await renderEmailPreview()
    }
  }

  function fitPreviewFrame() {
    const frame = previewIframeRef
    const doc = frame?.contentDocument
    if (!doc) return
    const viewport = frame.closest('.email-preview-viewport')
    const viewportWidth = Math.max((viewport?.clientWidth || PREVIEW_TARGET_WIDTH) - 16, 320)
    const root = doc.documentElement
    const body = doc.body
    const rawWidth = Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0, PREVIEW_TARGET_WIDTH)
    const widthCandidates = Array.from(doc.querySelectorAll('[width]'))
      .map((node) => Number.parseInt(node.getAttribute('width') || '', 10))
      .filter((value) => Number.isFinite(value) && value >= 320 && value <= 900)
    const has600Layout = widthCandidates.some((value) => Math.abs(value - PREVIEW_TARGET_WIDTH) <= 4)
    const contentWidth = has600Layout ? PREVIEW_TARGET_WIDTH : Math.min(rawWidth, PREVIEW_TARGET_WIDTH)
    const contentHeight = Math.max(root?.scrollHeight || 0, body?.scrollHeight || 0, 1)
    const scale = Math.min(1, viewportWidth / contentWidth)
    previewContentWidth = contentWidth
    previewContentHeight = contentHeight
    previewScale = scale
    previewScaledWidth = Math.max(320, Math.round(contentWidth * scale))
  }

  function handlePreviewFrameLoad() {
    fitPreviewFrame()
    setTimeout(fitPreviewFrame, 80)
    setTimeout(fitPreviewFrame, 250)
    setTimeout(fitPreviewFrame, 700)
    setTimeout(injectPreviewClickDetection, 100)
  }

  function injectPreviewClickDetection() {
    const doc = previewIframeRef?.contentDocument
    if (!doc?.body || doc.body.dataset.z51click) return
    doc.body.dataset.z51click = '1'
    const script = doc.createElement('script')
    script.textContent = `(function(){
      document.addEventListener('click', function(e) {
        var el = e.target;
        while (el && el.tagName !== 'BODY') {
          var tag = (el.tagName || '').toLowerCase();
          var text = (el.textContent || '').trim();
          if (text.length > 1 && text.length < 600 &&
              ['td','p','a','span','h1','h2','h3','h4','div'].includes(tag)) {
            var r = el.getBoundingClientRect();
            window.parent.postMessage({
              type:'z51-preview-click', text:text, tag:tag,
              rect:{top:r.top,left:r.left,width:r.width,height:r.height}
            },'*');
            e.preventDefault(); return;
          }
          el = el.parentElement;
        }
      }, true);
    })();`
    doc.head.appendChild(script)
  }

  function normalizeMatchText(t) {
    return (t || '').replace(/&nbsp;/g, ' ').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim()
  }

  function handlePreviewElementClick({ text, rect }) {
    const clickedText = normalizeMatchText(text)
    if (!clickedText) return
    const editableTypes = ['text', 'list-item', 'mixin-text']
    for (const block of assembledBlocks) {
      for (const field of (block.schema || [])) {
        if (!editableTypes.includes(field.type)) continue
        const fieldText = normalizeMatchText(getFieldValue(block, field))
        if (fieldText.length < 2) continue
        if (clickedText.includes(fieldText) || fieldText.includes(clickedText)) {
          openQuickEdit(block, field, rect)
          return
        }
      }
    }
    quickEditVisible = false
  }

  function openQuickEdit(block, field, iframeRect) {
    const frameEl = previewIframeRef
    if (!frameEl) return
    quickEditBlock = block
    quickEditField = field
    quickEditValue = getFieldValue(block, field) || ''
    const fb = frameEl.getBoundingClientRect()
    const sc = previewScale
    const PW = 340, PH = 160, M = 8
    let top = fb.top + (iframeRect.top + iframeRect.height) * sc + M
    let left = fb.left + iframeRect.left * sc
    if (left + PW > window.innerWidth - M) left = window.innerWidth - PW - M
    if (left < M) left = M
    if (top + PH > window.innerHeight - M) top = fb.top + iframeRect.top * sc - PH - M
    if (top < M) top = M
    quickEditPos = { top, left }
    quickEditVisible = true
  }

  function closeQuickEdit() {
    quickEditVisible = false
    quickEditBlock = null
    quickEditField = null
  }

  function handleQuickEditInput(e) {
    quickEditValue = e.target.value
    if (quickEditBlock && quickEditField) updateField(quickEditBlock, quickEditField, quickEditValue)
  }

  function handleQuickEditKeydown(e) {
    if (e.key === 'Escape') { closeQuickEdit(); return }
    if (e.key === 'Enter' && e.shiftKey) {
      e.preventDefault()
      const ta = e.target
      const start = ta.selectionStart
      const end = ta.selectionEnd
      quickEditValue = quickEditValue.slice(0, start) + '<br>' + quickEditValue.slice(end)
      if (quickEditBlock && quickEditField) updateField(quickEditBlock, quickEditField, quickEditValue)
      setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + 4 }, 0)
    }
  }

  // CodeMirror keymap для quick-edit
  const quickEditKeys = [
    { key: 'Escape', run: () => { closeQuickEdit(); return true } },
    { key: 'Shift-Enter', run: (cmView) => {
      const { from, to } = cmView.state.selection.main
      cmView.dispatch({ changes: { from, to, insert: '<br>' } })
      if (quickEditBlock && quickEditField) updateField(quickEditBlock, quickEditField, cmView.state.doc.toString())
      return true
    }},
  ]

  function insertBrAtCursor(e, block, field) {
    if (e.key !== 'Enter' || !e.shiftKey) return
    e.preventDefault()
    const el = e.target
    const start = el.selectionStart
    const end = el.selectionEnd
    const current = getFieldValue(block, field)
    updateField(block, field, current.slice(0, start) + '<br>' + current.slice(end))
    setTimeout(() => { el.selectionStart = el.selectionEnd = start + 4 }, 0)
  }

  function clearAll() {
    assembledBlocks = []
    rebuildOutput()
    saveDraft()
  }

  function savePresets() {
    apiSavePresets(currentProject, presets)
  }

  function getUniquePresetName(base) {
    const existing = new Set(presets.map((preset) => preset.name))
    if (!existing.has(base)) return base
    let counter = 2
    let next = `${base} (${counter})`
    while (existing.has(next)) {
      counter += 1
      next = `${base} (${counter})`
    }
    return next
  }

  function savePreset() {
    const name = presetName.trim()
    if (!name) return
    const existingIndex = presets.findIndex((preset) => preset.name === name)
    const shouldUpdate =
      existingIndex !== -1 &&
      typeof window !== 'undefined' &&
      window.confirm('Пресет с таким названием уже есть. Обновить его? Нажмите Отмена, чтобы создать новый.')
    const preset = {
      id: createId(),
      name,
      savedAt: new Date().toISOString(),
      blocks: assembledBlocks.map((block) => ({
        name: block.name,
        content: block.content,
        collapsed: true,
        schema: ensureSchema(block),
        addSpacing: block.addSpacing ?? true,
        spacing: typeof block.spacing === 'number' ? block.spacing : undefined,
      })),
    }
    if (existingIndex !== -1 && shouldUpdate) {
      const updated = { ...presets[existingIndex], ...preset, id: presets[existingIndex].id, name }
      presets = [updated, ...presets.filter((_, index) => index !== existingIndex)]
    } else {
      const newPreset = { ...preset, name: getUniquePresetName(name) }
      presets = [newPreset, ...presets]
    }
    presetName = ''
    showPresetInput = false
    savePresets()
  }

  function openPresetModal(letter) {
    presetSourceLetter = letter || null
    presetNameDraft = ''
    presetError = ''
    showPresetModal = true
  }

  async function createPresetFromLetter() {
    const name = presetNameDraft.trim()
    if (!name) {
      presetError = 'Укажи название пресета.'
      return
    }
    const baseLetter = presetSourceLetter
    if (!baseLetter?.id) {
      presetError = 'Не найдено письмо.'
      return
    }
    let blocks = []
    try {
      const data = await apiGetLetter(currentProject, baseLetter.id)
      blocks = normalizeDraftBlocks(data?.letter?.blocks || [])
    } catch {
      presetError = 'Не удалось загрузить письмо.'
      return
    }
    const preset = {
      id: createId(),
      name,
      savedAt: new Date().toISOString(),
      blocks,
    }
    presets = [preset, ...presets]
    showPresetModal = false
    presetNameDraft = ''
    presetSourceLetter = null
    savePresets()
  }

  function applyPresetBlocks(preset) {
    if (!requireLetter()) return
    const normalizedPreset = normalizeDraftBlocks(preset.blocks || [])
    assembledBlocks = mapDraftBlocks(normalizedPreset)
    rebuildOutput()
    saveDraft()
    setActivePage('builder')
  }

  function deletePresetById(id) {
    presets = presets.filter((preset) => preset.id !== id)
    savePresets()
  }

  function applyPreset(preset) {
    const normalizedPreset = normalizeDraftBlocks(preset.blocks || [])
    assembledBlocks = normalizedPreset.map((block) => ({
      id: createId(),
      name: block.name,
      content: block.content,
      collapsed: true,
      schema: buildBaseSchema(block.content, block.name, settings?.mixinRules),
      addSpacing: block.addSpacing ?? true,
      spacing: typeof block.spacing === 'number' ? block.spacing : undefined,
    }))
    presetName = preset.name || ''
    showPresetInput = true
    rebuildOutput()
    saveDraft()
    setActivePage('builder')
  }

  function deletePreset(id) {
    presets = presets.filter((preset) => preset.id !== id)
    savePresets()
  }

  function formatPresetDate(value) {
    if (!value) return ''
    const date = new Date(value)
    if (Number.isNaN(date.getTime())) return value
    return date.toLocaleString('ru-RU', {
      day: '2-digit',
      month: '2-digit',
      year: 'numeric',
      hour: '2-digit',
      minute: '2-digit',
    })
  }

  function getFilteredPresets() {
    const query = presetSearch.trim().toLowerCase()
    let list = [...presets]
    if (query) {
      list = list.filter((preset) => preset.name.toLowerCase().includes(query))
    }
    list.sort((a, b) => {
      const timeA = new Date(a.savedAt || 0).getTime()
      const timeB = new Date(b.savedAt || 0).getTime()
      return presetSort === 'asc' ? timeA - timeB : timeB - timeA
    })
    return list
  }

  function buildDraftPayload() {
    return assembledBlocks.map((block) => ({
      name: block.name,
      content: block.content,
      collapsed: block.collapsed,
      schema: ensureSchema(block),
      addSpacing: block.addSpacing ?? true,
      spacing: typeof block.spacing === 'number' ? block.spacing : undefined,
    }))
  }

  function saveDraft() {
    isDirty = true
    saveErrorMsg = ''
    if (saveTimer) clearTimeout(saveTimer)
    saveTimer = setTimeout(flushSave, 600)
  }

  async function flushSave() {
    if (isSaving) {
      pendingSave = true
      return
    }
    isSaving = true
    try {
      const payload = buildDraftPayload()
      if (currentLetterId) {
        const now = new Date().toISOString()
        const existingIndex = letters.findIndex((letter) => letter.id === currentLetterId)
        const nextLetter = {
          id: currentLetterId,
          name: currentLetterName || buildLetterName(currentLetterTitle, currentLetterDate),
          title: currentLetterTitle,
          date: currentLetterDate,
          createdAt: existingIndex !== -1 ? letters[existingIndex].createdAt : now,
          updatedAt: now,
          ...(assemblyInfo ? { assemblyInfo } : {}),
        }
        const nextList =
          existingIndex === -1
            ? [nextLetter, ...letters]
            : letters.map((letter) => (letter.id === currentLetterId ? nextLetter : letter))
        letters = nextList
        await apiSaveLetters(currentProject, { list: nextList, currentId: currentLetterId })
        await apiSaveLetter(currentProject, { ...nextLetter, blocks: payload })
      } else {
        await apiSaveDraft(currentProject, payload)
      }
      isDirty = false
      saveErrorMsg = ''
    } catch (e) {
      saveErrorMsg = e?.message || 'Ошибка сохранения'
    } finally {
      isSaving = false
      if (pendingSave) {
        pendingSave = false
        flushSave()
      }
    }
  }

  function handleGlobalKeydown(event) {
    const mod = event.ctrlKey || event.metaKey
    if (!mod) return
    if (event.key === 's') {
      event.preventDefault()
      flushSave()
    } else if (event.key === 'g') {
      event.preventDefault()
      renderEmailPreview()
    } else if (event.key === 'z') {
      const tag = event.target?.tagName
      if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
      event.preventDefault()
      undoLast()
    }
  }

</script>

<svelte:window on:keydown={handleGlobalKeydown} />

{#if !authChecked}
  <div class="login-page"><div class="login-loading">Загрузка...</div></div>
{:else if !currentUser}
  <div class="login-page" style="background-image: url('{loginBg}')">
    <form class="login-card" on:submit|preventDefault={handleLogin}>
      <div class="login-title">ASPEKTER</div>
      <input
        class="field-control"
        type="text"
        placeholder="Логин"
        bind:value={loginForm.login}
        autocomplete="username"
      />
      <input
        class="field-control"
        type="password"
        placeholder="Пароль"
        bind:value={loginForm.password}
        autocomplete="current-password"
      />
      {#if loginError}<div class="field-error">{loginError}</div>{/if}
      <button class="btn btn-primary login-btn" type="submit" disabled={loginLoading}>
        {loginLoading ? 'Вход...' : 'Войти'}
      </button>
    </form>
  </div>
{:else}

<div class="page" on:click={() => (projectMenuOpen = false)}>
  <aside class="control-panel panel-sidebar">
    <div class="brand" on:click|stopPropagation>
      <button
        type="button"
        class="brand-button"
        on:click|stopPropagation={() => (projectMenuOpen = !projectMenuOpen)}
        aria-label="Выбрать проект"
        title="Выбрать проект"
      >
        {#if settings.logoData}
          <img class="brand-logo" src={settings.logoData} alt={settings.projectTitle || currentProject} />
        {:else}
          <p class="brand-mark">{settings.projectTitle || currentProject}</p>
        {/if}
        <p class="brand-sub">ASPEKTER</p>
      </button>
      {#if projectMenuOpen}
        <div class="brand-menu" on:click|stopPropagation>
          {#each projects as projectName}
            <button
              type="button"
              class:selected={projectName === currentProject}
              class="brand-menu-item"
              on:click={() => selectProjectByName(projectName)}
            >
              {projectName}
            </button>
          {/each}
        </div>
      {/if}
    </div>

    <div class="sidebar-group">
      <div class="sidebar-title-row">
        <button class="sidebar-title sidebar-title-accent sidebar-collapse-btn" on:click={() => presetsCollapsed = !presetsCollapsed}>
          Пресеты {presets.length ? `(${presets.length})` : ''} <span class="collapse-arrow" class:collapsed={presetsCollapsed}>▾</span>
        </button>
        {#if currentLetterId}
          <button
            class="btn ghost sidebar-title-btn"
            on:click={() => openPresetModal(letters.find((l) => l.id === currentLetterId))}
            title="Сохранить текущее письмо как пресет"
          >+</button>
        {/if}
      </div>
      {#if !presetsCollapsed && presets.length > 0}
        <div class="preset-quick-list">
          {#each presets as preset}
            <button class="btn preset-pill-btn" on:click={() => applyPresetBlocks(preset)}>
              {preset.name}
            </button>
          {/each}
        </div>
        <div class="sidebar-separator"></div>
      {/if}
      <div class="sidebar-title sidebar-title-accent">Добавить блок</div>
      <div class="add-block">
        <div class="block-dropdown" bind:this={blockDropdownRef} on:click|stopPropagation>
          <button class="field-control block-dropdown-trigger"
            on:click={() => { blockDropdownOpen = !blockDropdownOpen; blockDropdownSearch = '' }}>
            Выбери блок ▾
          </button>
          {#if blockDropdownOpen}
            <div class="block-dropdown-menu">
              <input class="block-dropdown-search field-control" type="text" placeholder="Поиск..."
                bind:value={blockDropdownSearch} on:keydown|stopPropagation />
              <div class="block-dropdown-list">
                {#each allBlocks as block, index}
                  {@const matches = !blockDropdownSearch.trim() || block.name.toLowerCase().includes(blockDropdownSearch.toLowerCase())}
                  {#if matches}
                    <div class="block-dropdown-item" on:click={() => selectBlockFromDropdown(index)}>
                      <span class="block-dropdown-item-name">{block.name}</span>
                      <span class="block-type-tag">{getBlockTypeTag(block)}</span>
                    </div>
                  {/if}
                {/each}
              </div>
            </div>
          {/if}
        </div>
      </div>
      <div class="quick-blocks">
        {#if quickBlocksView.length === 0}
          <div class="muted">Быстрых кнопок пока нет</div>
        {:else}
          {#each quickBlocksView as quickName}
            <button class="btn icon-square btn-textual" on:click={() => addQuickBlockByName(quickName)}>
              {getQuickBlockLabel(quickName)}
            </button>
          {/each}
        {/if}
      </div>
      {#if hasFeed && hasProductMixins}
        <button class="btn btn-accent auto-assemble-btn" on:click={() => { autoAssembleOpen = true; autoAssembleBlocks = []; autoAssembleError = ''; }}>
          🎲 Автоподбор монет
        </button>
      {/if}
      <div class="sidebar-separator"></div>
      <div class="sidebar-note">
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <div class="sidebar-title sidebar-title-accent clickable" on:click={() => sidebarNoteCollapsed = !sidebarNoteCollapsed}>
          Заметки {sidebarNoteCollapsed ? '▸' : '▾'}
        </div>
        {#if !sidebarNoteCollapsed}
          <textarea
            class="field-control sidebar-note-textarea"
            placeholder="Заметка проекта"
            value={settings.sidebarNote || ''}
            bind:this={sidebarNoteTextareaRef}
            on:input={(event) => {
              const value = event.target.value
              saveSettings({ ...settings, sidebarNote: value })
              autoResizeTextarea(event.target)
            }}
          ></textarea>
        {/if}
      </div>
      {#if assemblyInfo && !assemblyCardHidden}
        <div class="sidebar-separator"></div>
        <div class="acard">
          <div class="acard-header">
            <span class="acard-badge">{assemblyInfo.status || 'сборка'}</span>
            <button class="acard-close" on:click={() => assemblyCardHidden = true} title="Скрыть">&times;</button>
          </div>
          <div class="acard-subject">{assemblyInfo.subject}</div>
          {#if assemblyInfo.preheader}
            <div class="acard-preheader">{assemblyInfo.preheader}</div>
          {/if}
          {#if (assemblyInfo.extra || []).some(e => !e.link && !isUrl(e.value))}
            <div class="acard-tags">
              {#each (assemblyInfo.extra || []).filter(e => !e.link && !isUrl(e.value)) as ext}
                <span class="acard-tag" title={ext.label}>{ext.value}</span>
              {/each}
            </div>
          {/if}
          {#if (assemblyInfo.extra || []).some(e => e.link || isUrl(e.value))}
            <div class="acard-links">
              {#each (assemblyInfo.extra || []).filter(e => e.link || isUrl(e.value)) as ext}
                <a class="acard-link" href={ext.link || ext.value} rel="noopener"
                  on:click|preventDefault={(e) => window.open(ext.link || ext.value, '_blank', `width=700,height=1000,left=${screen.width - 720},top=50`)}>
                  <svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M6.5 3.5H3.5C2.948 3.5 2.5 3.948 2.5 4.5v8c0 .552.448 1 1 1h8c.552 0 1-.448 1-1V9.5M9.5 2.5h4v4M7.5 8.5l6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
                  {ext.label}
                </a>
              {/each}
            </div>
          {/if}
        </div>
        <div class="acard-actions">
          {#if settings.mailingService?.url}
            <button class="btn acard-mailing-btn"
              on:click={() => window.open(settings.mailingService?.url, '_blank', 'width=1200,height=800')}>
              {settings.mailingService?.label || 'Создать рассылку'}
            </button>
          {/if}
          {#if assemblyInfo?.status !== 'отправлено'}
            <button class="btn acard-sent-btn" on:click={() => updateAssemblyStatus('отправлено')}>Отправлено</button>
          {/if}
        </div>
      {/if}
      {#if acardPlanRows.length > 0}
        {#if !assemblyInfo || assemblyCardHidden}<div class="sidebar-separator"></div>{/if}
        <div class="acard-plan">
          <!-- svelte-ignore a11y-click-events-have-key-events -->
          <div class="acard-plan-title" on:click={() => acardCalCollapsed = !acardCalCollapsed}>
            Календарь {acardCalCollapsed ? '▸' : '▾'}
          </div>
          {#if !acardCalCollapsed}
            <div class="acard-cal">
              <div class="acard-cal-nav">
                <button class="acard-cal-arrow" on:click={acardCalPrev}>‹</button>
                <span class="acard-cal-month">{acardCalMonth.toLocaleString('ru-RU', { month: 'long', year: 'numeric' })}</span>
                <button class="acard-cal-arrow" on:click={acardCalNext}>›</button>
              </div>
              <div class="acard-cal-grid">
                {#each ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'] as dow}
                  <span class="acard-cal-dow">{dow}</span>
                {/each}
                {#each acardCalCells as cell}
                  {#if cell === null}
                    <span class="acard-cal-empty"></span>
                  {:else}
                    <!-- svelte-ignore a11y-click-events-have-key-events -->
                    <span
                      class="acard-cal-day"
                      class:acard-cal-today={cell.isToday}
                      class:acard-cal-has={cell.rows.length > 0}
                      class:acard-cal-clickable={cell.rows.length > 0}
                      title={cell.rows.map(r => r.subject + (r.status ? ` (${r.status})` : '')).join('\n')}
                      on:click={() => { if (cell.rows.length === 1) startAssemble(cell.rows[0]) }}
                    >
                      {cell.d}
                      {#if cell.rows.length > 0}
                        <span class="acard-cal-dots">
                          {#each cell.rows as r}
                            <span class="acard-cal-dot plan-dot-{planStatusClass(r.status)}" class:acard-cal-dot-past={cell.isPast && planStatusClass(r.status) === 'done'}></span>
                          {/each}
                        </span>
                      {/if}
                    </span>
                  {/if}
                {/each}
              </div>
            </div>
          {/if}
          <!-- svelte-ignore a11y-click-events-have-key-events -->
          <div class="acard-plan-title" style="margin-top: 8px" on:click={() => acardListCollapsed = !acardListCollapsed}>
            Список {acardListCollapsed ? '▸' : '▾'}
          </div>
          {#if !acardListCollapsed}
            {#each acardPlanRows.filter(r => { const today = new Date(); today.setHours(0,0,0,0); return !(r.date < today && planStatusClass(r.status) === 'done') }) as row}
              <!-- svelte-ignore a11y-click-events-have-key-events -->
              <div class="acard-plan-row" class:acard-plan-overdue={row.date < new Date(new Date().setHours(0,0,0,0)) && planStatusClass(row.status) !== 'done'} on:click={() => startAssemble(row)}>
                <span class="acard-plan-date">{row.dateStr}</span>
                <div class="acard-plan-dot plan-dot-{planStatusClass(row.status)}"></div>
                <span class="acard-plan-subject">{row.subject}</span>
              </div>
            {/each}
          {/if}
        </div>
      {/if}
    </div>
  </aside>

  <div class="workspace">
    <div class="workspace-header">
      <div class="workspace-header-left">
        <div class="workspace-meta-row">
          {#if activePage !== 'builder'}
            <button class="btn" on:click={() => setActivePage('builder')} title="Главная">
              Главная
            </button>
          {/if}
          <button
            class="btn icon-square"
            on:click={() => openLetterModal('new')}
            aria-label="Новое письмо"
            title="Новое письмо"
          >
            <img class="icon-img" src={newMailIcon} alt="" />
          </button>
          <button
            class="btn icon-square"
            on:click={() => (currentLetterId ? persistCurrentLetter() : openLetterModal('new'))}
            aria-label="Сохранить письмо"
            title="Сохранить письмо"
          >
            <img class="icon-img" src={saveIcon} alt="" />
          </button>
          <span
            class={`autosave-dot ${isDirty || isSaving ? 'active' : ''} ${saveErrorMsg ? 'error' : ''}`}
            title={saveErrorMsg || (isDirty || isSaving ? 'Сохранение...' : 'Все изменения сохранены')}
            aria-hidden="true"
          ></span>
          {#if saveErrorMsg}
            <span class="save-error-label" title={saveErrorMsg}>
              Ошибка сохранения
              <button type="button" class="save-error-dismiss" on:click={() => (saveErrorMsg = '')}>×</button>
            </span>
          {/if}
          <button
            class="btn icon-square"
            on:click={() => setActivePage('presets')}
            aria-label="Пресеты"
            title="Пресеты"
          >
            <img class="icon-img" src={filesIcon} alt="" />
          </button>
          {#if activePage === 'builder'}
            <button
              class="btn icon-square"
              on:click={undoLast}
              aria-label="Отменить последнее действие"
              title="Отменить последнее действие"
            >
              <img class="icon-img" src={backIcon} alt="" />
            </button>
            <button
              class="btn icon-square"
              on:click={() => setAllCollapsed(assembledBlocks.some((block) => !block.collapsed))}
              aria-label={assembledBlocks.some((block) => !block.collapsed) ? 'Свернуть все' : 'Развернуть все'}
              title={assembledBlocks.some((block) => !block.collapsed) ? 'Свернуть все' : 'Развернуть все'}
            >
              <img class="icon-img" src={stackIcon} alt="" />
            </button>
            <button class="btn icon-square" on:click={clearAll} aria-label="Сброс" title="Сброс">
              <img class="icon-img" src={clearIcon} alt="" />
            </button>
            {#if productOptionsList.length > 0}
              <div class="product-options-bar">
                {#each productOptionsList as option}
                  <button
                    class={`btn icon-square btn-textual ${isOptionEnabledInAnyBlock(option) ? 'active' : ''} ${isOptionEnabledInAnyBlock(option) && !isOptionEnabledInAllBlocks(option) ? 'partial' : ''}`}
                    aria-pressed={isOptionEnabledInAnyBlock(option)}
                    data-state={isOptionEnabledInAnyBlock(option)
                      ? isOptionEnabledInAllBlocks(option)
                        ? 'on'
                        : 'partial'
                      : 'off'}
                    on:click={() => toggleProductOptionForAll(option)}
                    title={`Опция для всех товаров: ${option.label}`}
                  >
                    {option.label}
                  </button>
                {/each}
              </div>
            {/if}
          {/if}
        </div>
        {#if currentLetterName}
          <div class="letter-title" title={currentLetterName}>
            {#if isEditingLetterDate}
              <input
                class="letter-date-input"
                type="date"
                bind:this={letterDateInput}
                bind:value={letterDateDraft}
                on:blur={commitEditLetterDate}
                on:keydown={(event) => {
                  if (event.key === 'Enter') commitEditLetterDate()
                  if (event.key === 'Escape') cancelEditLetterDate()
                }}
              />
            {:else}
              <span class="letter-date" on:click={startEditLetterDate}>
                {formatLetterDate(currentLetterDate)}
              </span>
            {/if}
            <span class="letter-gap"> </span>
            {#if isEditingLetterName}
              <input
                class="letter-title-input"
                bind:this={letterNameInput}
                bind:value={letterNameDraft}
                size={Math.max(12, letterNameDraft.length)}
                on:blur={commitEditLetterName}
                on:keydown={(event) => {
                  if (event.key === 'Enter') commitEditLetterName()
                  if (event.key === 'Escape') cancelEditLetterName()
                }}
              />
            {:else}
              <span class="letter-name" on:click={startEditLetterName}>
                {currentLetterTitle}
              </span>
            {/if}
          </div>
        {/if}
      </div>
      <div class="workspace-header-actions">
        <button
          class={`btn icon-square ${activePage === 'settings' ? 'toggled' : ''}`}
          on:click={() => setActivePage('settings')}
          title="Настройки"
          aria-label="Настройки"
        >
          <img class="icon-img" src={settingsIcon} alt="" />
        </button>
        <button
          class={`btn ${activePage === 'plan' ? 'toggled' : ''}`}
          on:click={() => setActivePage('plan')}
          title="План"
          aria-label="План"
        >
          План
        </button>
        <button
          class={`btn ${activePage === 'stats' ? 'toggled' : ''}`}
          on:click={() => { setActivePage('stats'); loadAllStats() }}
          title="Статистика"
          aria-label="Статистика"
        >
          Стат
        </button>
        <div class="theme-toggle">
          <label class="theme-switch">
            <input
              type="checkbox"
              checked={theme === 'dark'}
              on:change={(event) => setTheme(event.target.checked ? 'dark' : 'light')}
            />
            <span class="theme-slider"></span>
          </label>
        </div>
      </div>
    </div>

    {#if activePage === 'builder'}
      <div class="panel-heading">
        <div>
        </div>
      </div>

      {#if loadError}
        <div class="error">{loadError}</div>
      {/if}

      <div class="workspace-grid builder-grid" class:preview-focus={outputPanelMode === 'preview'}>
        <div class="panel panel-left">
          {#if assembledBlocks.length === 0}
            <div class="empty">Добавь блоки слева, чтобы начать сборку.</div>
          {:else}
            <div class="blocks-container" bind:this={blocksScrollRef}>
              {#each assembledBlocks as block, index (block.id)}
                <div
                  class={`block-card ${block.collapsed ? 'collapsed' : ''} ${dragOverBlockIndex === index && dragBlockIndex !== index ? 'block-drag-over' : ''} ${dragBlockIndex === index ? 'block-dragging' : ''}`}
                  data-block-id={block.id}
                  draggable="true"
                  on:dragstart={(e) => handleBlockDragStart(e, index)}
                  on:dragover={(e) => handleBlockDragOver(e, index)}
                  on:drop={(e) => handleBlockDrop(e, index)}
                  on:dragend={handleBlockDragEnd}
                >
                  <div class="block-header">
                  <div
                    class="block-title clickable"
                    role="button"
                    tabindex="0"
                    on:click={() => { toggleBlock(block.id); setActiveBlock(block.id, true) }}
                    on:keydown={(event) => event.key === 'Enter' && toggleBlock(block.id)}
                  >
                    <span class="drag-handle" title="Перетащить">⠿</span>
                    <span class="block-num">{String(index + 1).padStart(2, '0')}</span>
                    {block.name}
                  </div>
                    <div class="block-actions">
                      <button class="btn icon-square" on:click={() => moveBlock(index, -1)} aria-label="Поднять блок">▲</button>
                      <button class="btn icon-square" on:click={() => moveBlock(index, 1)} aria-label="Опустить блок">▼</button>
                      <button class="btn icon-square danger" on:click={() => removeBlock(block.id)} aria-label="Удалить блок">×</button>
                    </div>
                  </div>

                  {#if !block.collapsed}
                    {#if visualBlocks.has(block.id) && parseProductsMixin(block.content)}
                      {@const _vparsed = parseProductsMixin(block.content)}
                      <div class="products-visual-editor">
                        <table class="tz-table">
                          <thead>
                            <tr>
                              <th style="width:75px">ID</th>
                              <th style="width:150px">Имя</th>
                              <th>Описание</th>
                              <th style="width:80px">Ст. цена</th>
                              <th style="width:80px">Цена</th>
                              <th style="width:46px">%</th>
                              <th>Ссылка</th>
                              <th style="width:28px"></th>
                            </tr>
                          </thead>
                          <tbody>
                            {#each _vparsed.products as p, pi}
                              <tr>
                                <td><input class="tz-cell" value={p.id} on:input={e => updateProductField(block.id, pi, 'id', e.target.value)} /></td>
                                <td><input class="tz-cell" value={p.name} on:input={e => updateProductField(block.id, pi, 'name', e.target.value)} /></td>
                                <td><input class="tz-cell" value={p.category} on:input={e => updateProductField(block.id, pi, 'category', e.target.value)} /></td>
                                <td><input class="tz-cell tz-cell-num" value={p.oldprice} on:input={e => updateProductField(block.id, pi, 'oldprice', e.target.value)} /></td>
                                <td><input class="tz-cell tz-cell-num" value={p.price} on:input={e => updateProductField(block.id, pi, 'price', e.target.value)} /></td>
                                <td><input class="tz-cell tz-cell-num" value={p.discount} on:input={e => updateProductField(block.id, pi, 'discount', e.target.value)} /></td>
                                <td><input class="tz-cell" value={p.url} on:input={e => updateProductField(block.id, pi, 'url', e.target.value)} /></td>
                                <td><button class="btn ghost" style="padding:2px 4px;font-size:12px;color:var(--danger,#e55)" on:click={() => removeProductRow(block.id, pi)}>✕</button></td>
                              </tr>
                            {/each}
                          </tbody>
                        </table>
                        <button class="btn ghost" style="font-size:11px;margin:6px 0 4px" on:click={() => addProductRow(block.id)}>+ Добавить товар</button>
                      </div>
                    {:else}
                    {@const _parsed = parseSections(block.content)}
                    {@const _allFields = applySchemaSettings(ensureSchema(block), block.name)}
                    {@const _secHeaders = buildSectionHeaders(_allFields, _parsed)}
                    <div class="block-body">
                      {#each _allFields as field, fieldIndex (fieldKey(block, field))}
                        {#if _secHeaders.has(fieldIndex) && _secHeaders.get(fieldIndex).si > 0}
                          <div class="block-section-divider"></div>
                        {/if}
                        {@const key = fieldKey(block, field)}
                        {@const _sh = _secHeaders.get(fieldIndex)}
                        {@const _prevSectionLabel = _sh ? _sh.section.label : ''}
                        <div>
                          <div class="field-head" class:field-head--empty={!field.label}>
                            {#if (field.type !== 'text' && field.type !== 'list-item') && field.label && (!_prevSectionLabel || field.label.toLowerCase() !== _prevSectionLabel.toLowerCase())}
                              <div class="field-label">
                                {field.label}
                              </div>
                              {#if field.type === 'mixin-text' && normalizeNewlines(block.content).split('\n')[field.lineIndex]?.trim().startsWith('+buttonRounded')}
                                <button class="btn icon-square danger" title="Удалить кнопку" on:click={() => removeButtonRow(block.id)}>×</button>
                              {/if}
                            {/if}
                            {#if field.type === 'text' || field.type === 'list-item'}
                              <div class="editor-actions">
                                <button
                                  class="btn icon-square"
                                  on:click={() => applyTypograf(block, field)}
                                  disabled={typografStatus[key]?.loading}
                                  title="Типограф"
                                >
                                  {typografStatus[key]?.loading ? '...' : 'T'}
                                </button>
                                <button class="btn icon-square" on:click={() => applyAutoBreaks(block, field)} title="Авто-переносы строк">↵</button>
                                {#if block.name === 'Текст' && fieldIndex === 0}
                                  <button class="btn icon-square btn-textual" on:click={() => addTextParagraph(block)}>
                                    Абзац
                                  </button>
                                {/if}
                                {#if block.name === 'Текст' && field.removable}
                                  <button class="btn icon-square danger" on:click={() => removeTextParagraph(block, field)} aria-label="Удалить абзац">
                                    ×
                                  </button>
                                {/if}
                                {#if isListBlock(block.name) && fieldIndex === 0}
                                  <button class="btn icon-square btn-textual" on:click={() => addListItem(block)}>
                                    Пункт
                                  </button>
                                {/if}
                                {#if isListBlock(block.name) && field.removable}
                                  <button class="btn icon-square danger" on:click={() => removeListItem(block, field)} aria-label="Удалить пункт">
                                    ×
                                  </button>
                                {/if}
                                <button class="btn icon-square" on:click={() => wrapBold(block, field)}>Ж</button>
                                <button class="btn icon-square" on:click={() => insertBullet(block, field)}>•</button>
                                <button class="btn icon-square" on:click={() => insertBr(block, field)} title="Вставить перенос строки">br</button>
                                <span class="link-group">
                                  <button class="btn icon-square link-btn" on:click={() => insertLink(block, field)}>A</button>
                                  <input
                                    class="field-control link-input"
                                    type="text"
                                    placeholder="URL"
                                    value={linkDrafts[key] ?? ''}
                                    on:input={(event) => updateLinkDraft(key, event.target.value)}
                                  />
                                </span>
                                <button class={`btn icon-square ${previewOpen[key] ? 'active' : ''}`} on:click={() => togglePreview(key)} aria-label="Предпросмотр">
                                  <svg class="icon-eye" viewBox="0 0 24 24" aria-hidden="true">
                                    <path
                                      d="M2.5 12s3.8-6 9.5-6 9.5 6 9.5 6-3.8 6-9.5 6-9.5-6-9.5-6Z"
                                      fill="none"
                                      stroke="currentColor"
                                      stroke-width="1.5"
                                    />
                                    <circle
                                      cx="12"
                                      cy="12"
                                      r="3"
                                      fill="none"
                                      stroke="currentColor"
                                      stroke-width="1.5"
                                    />
                                  </svg>
                                </button>
                                {#if _sh}
                                  <span class="section-actions-inline">
                                    <button class="btn icon-square" disabled={_sh.si === 0} on:click={() => moveSectionInBlock(block.id, _sh.si, -1)} title="Переместить вверх">↑</button>
                                    <button class="btn icon-square" disabled={_sh.si === _sh.total - 1} on:click={() => moveSectionInBlock(block.id, _sh.si, 1)} title="Переместить вниз">↓</button>
                                    <button class="btn icon-square danger" on:click={() => removeSectionFromBlock(block.id, _sh.si)} title="Удалить секцию">×</button>
                                  </span>
                                {/if}
                              </div>
                            {/if}
                          </div>
                        {#if field.type === 'raw'}
                          <textarea
                            class="singleline-textarea"
                            rows="6"
                            value={getFieldValue(block, field)}
                            on:input={(event) => updateField(block, field, event.target.value)}
                            use:autosize
                            on:focus={() => setActiveBlock(block.id)}
                          ></textarea>
                        {:else if isImageField(field)}
                          <div class="image-upload-row"
                            class:image-drag-over={dragOverField === key}
                            on:dragover|preventDefault={(e) => handleImageDragOver(e, block, field)}
                            on:dragleave={handleImageDragLeave}
                            on:drop|preventDefault={(e) => handleImageDrop(e, block, field)}>
                            <input
                              type="text"
                              value={getFieldValue(block, field)}
                              on:input={(event) => updateField(block, field, event.target.value)}
                              on:focus={() => setActiveBlock(block.id)}
                              placeholder="https://..."
                            />
                            {#if settings.ftpConfig?.host || settings.imageStorage === 'local'}
                              <button class="btn icon-square image-upload-btn" title="FTP: загрузить / выбрать"
                                on:click={() => openFtpGallery(block, field)}>
                                <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><line x1="12" y1="11" x2="12" y2="17"/><polyline points="9 14 12 11 15 14"/></svg>
                              </button>
                            {/if}
                            {#if uploadingFields[key]?.loading}<span class="image-upload-status">Загрузка…</span>{/if}
                          </div>
                          {#if uploadingFields[key]?.error}<p class="field-error">{uploadingFields[key].error}</p>{/if}
                          {#if looksLikeImageUrl(getFieldValue(block, field))}
                            <img class="image-preview-thumb" src={getFieldValue(block, field)} alt=""
                              on:error={(e) => { e.target.style.display = 'none' }}
                              on:load={(e) => { e.target.style.display = 'block' }} />
                          {/if}
                        {:else if field.type === 'select'}
                          {#if field.options?.every(o => o.color)}
                            <div class="color-swatches">
                              {#each (field.options ?? []) as opt}
                                <button
                                  class="color-swatch{getFieldValue(block, field) === opt.value ? ' active' : ''}"
                                  style="background:{opt.color}"
                                  title={opt.label}
                                  on:click={() => updateField(block, field, opt.value)}
                                ></button>
                              {/each}
                            </div>
                          {:else}
                            <select
                              value={getFieldValue(block, field)}
                              on:change={(e) => updateField(block, field, e.target.value)}
                              on:focus={() => setActiveBlock(block.id)}
                            >
                              {#each (field.options ?? []) as opt}
                                <option value={opt.value}>{opt.label}</option>
                              {/each}
                            </select>
                          {/if}
                        {:else if field.type === 'mixin-ids'}
                          <input
                            class="ids-input"
                            type="text"
                            value={getFieldValue(block, field)}
                            on:input={(event) => updateField(block, field, event.target.value)}
                            on:focus={() => setActiveBlock(block.id)}
                            placeholder="ID через запятую"
                          />
                        {:else if getFieldValue(block, field).length > 80 || field.type === 'text'}
                          <textarea
                            rows="2"
                            value={getFieldValue(block, field)}
                            on:input={(event) => updateField(block, field, event.target.value)}
                            on:keydown={(e) => insertBrAtCursor(e, block, field)}
                            use:textRef={key}
                            use:autosize
                            use:highlightDoubleSpaces={getFieldValue(block, field)}
                            on:focus={() => setActiveBlock(block.id)}
                          ></textarea>
                        {:else}
                          <input
                            type="text"
                            value={getFieldValue(block, field)}
                            on:input={(event) => updateField(block, field, event.target.value)}
                            on:keydown={(e) => { if (field.type === 'mixin-text' || field.type === 'text' || field.type === 'list-item') insertBrAtCursor(e, block, field) }}
                            use:highlightDoubleSpaces={getFieldValue(block, field)}
                            on:focus={() => setActiveBlock(block.id)}
                          />
                        {/if}
                        {#if /(?:&nbsp;|[ \t\u00A0]){2,}/.test(getFieldValue(block, field))}
                          <button class="double-space-warn" title="Нажмите чтобы убрать двойные пробелы"
                            on:click={() => updateField(block, field, getFieldValue(block, field).replace(/(?:&nbsp;|[ \t\u00A0]){2,}/g, m => m.includes('&nbsp;') ? '&nbsp;' : ' '))}>
                            ⚠ двойной пробел
                          </button>
                        {/if}
                        {#if (field.type === 'text' || field.type === 'list-item') && previewOpen[key]}
                          <div class="text-preview">
                            <div class="text-preview-frame">
                              {@html getFieldValue(block, field)}
                            </div>
                          </div>
                        {/if}
                        {#if (field.type === 'text' || field.type === 'list-item') && getFieldValue(block, field).includes('\n')}
                          <div class="linebreak-preview">
                            <span class="linebreak-label">Переносы строк:</span>
                            <div class="linebreak-content">
                              {@html visualizeLineBreaks(getFieldValue(block, field))}
                            </div>
                          </div>
                        {/if}
                        {#if typografStatus[key]?.error}
                          <p class="field-error">{typografStatus[key]?.error}</p>
                        {/if}
                        </div>
                      {/each}
                    </div>
                    {/if}

                    <div class="block-footer">
                      {#if parseProductsMixin(block.content)}
                        <button class="btn ghost" style="font-size:11px;padding:2px 8px" title="Визуальный редактор товаров" on:click={() => toggleVisualMode(block.id)}>
                          {visualBlocks.has(block.id) ? '< Поля' : '⊞ Таблица'}
                        </button>
                      {/if}
                      {#if isProductBlock(block.content)}
                        <div class="product-options-inline">
                          {#each productOptionsList as option}
                            <button
                              class={`btn icon-square btn-textual ${getProductOptionState(block)[parseOptionCode(option.code)?.key] === parseOptionCode(option.code)?.value ? 'active' : ''}`}
                              on:click={() => toggleProductOptionForBlock(block, option)}
                              title={`Опция товаров: ${option.label}`}
                            >
                              {option.label}
                            </button>
                          {/each}
                        </div>
                      {/if}
                      <div class={`spacing-control ${block.addSpacing ? 'active' : ''}`}>
                        <button
                          type="button"
                          class="spacing-toggle"
                          on:click={() => updateBlockSpacing(block.id, !block.addSpacing)}
                          aria-pressed={block.addSpacing}
                        >
                          <img class="icon-img" src={spacerIcon} alt="" />
                        </button>
                        <input
                          class="spacing-value"
                          type="text"
                          inputmode="numeric"
                          pattern="[0-9]*"
                          maxlength="3"
                          value={getSpacingForBlock(block)}
                          on:input={(event) => updateBlockSpacingValue(block.id, event.target.value)}
                          disabled={!block.addSpacing}
                        />
                      </div>
                      <button class="btn icon-square" on:click={() => togglePug(block.id)} aria-label="PUG" title="PUG">
                        <img class="icon-img" src={pugIcon} alt="" />
                      </button>
                      {#if settings?.retailcrmConfig?.url && block.content.includes('products')}
                        <button class="btn icon-square" title="Вставить из ТЗ" on:click={() => { tzModalBlockId = block.id; tzModalText = ''; tzModalResults = []; tzModalError = '' }}>📋</button>
                      {/if}
                      <button class="btn icon-square" on:click={() => toggleAddMenu(block.id)} aria-label="Добавить блок после" title="Добавить блок после">
                        <span class="icon-svg accent" aria-hidden="true">
                          {@html plusIconSvg}
                        </span>
                      </button>
                      {#if addMenuFor === block.id}
                        <select
                          class="field-control add-inline"
                          data-add-menu={block.id}
                          on:change={(event) => handleAddAfter(index, event)}
                        >
                          <option value="" disabled selected>Выбери блок</option>
                          {#each allBlocks as blockOption, blockOptionIdx}
                            {#if !blockOption.isCustom}
                              <option value={blockOptionIdx}>{blockOption.name}</option>
                            {/if}
                          {/each}
                          {#if allBlocks.some(b => b.isCustom)}
                            <optgroup label="Пользовательские">
                              {#each allBlocks as blockOption, blockOptionIdx}
                                {#if blockOption.isCustom}
                                  <option value={blockOptionIdx}>{blockOption.name}</option>
                                {/if}
                              {/each}
                            </optgroup>
                          {/if}
                        </select>
                      {/if}
                    </div>
                    {#if pugOpen[block.id]}
                      <CodeEditor
                        value={block.content}
                        rows={16}
                        onchange={(v) => { updateBlockContent(block.id, v); setActiveBlock(block.id) }}
                      />
                    {/if}
                  {/if}
                </div>
              {/each}
            </div>
          {/if}
        </div>

        <div class="panel panel-right">
          <div class="code-panel-header">
            <div>
              <div class="code-panel-title">
                {outputPanelMode === 'pug' ? 'PUG' : outputPanelMode === 'html' ? 'HTML' : 'Предпросмотр'}
              </div>
              <div class="code-panel-subtitle">
                {outputPanelMode === 'pug'
                  ? 'Итоговый файл'
                  : outputPanelMode === 'html'
                    ? 'Сгенерированный index.html'
                    : previewGeneratedAt
                      ? new Date(previewGeneratedAt).toLocaleString('ru-RU')
                      : 'Готовое письмо после генерации'}
              </div>
            </div>
            <div class="code-panel-actions">
              <button
                class={`btn ${outputPanelMode === 'pug' ? 'toggled' : ''}`}
                on:click={() => setOutputPanelMode('pug')}
              >
                PUG
              </button>
              <button
                class={`btn ${outputPanelMode === 'preview' ? 'toggled' : ''}`}
                on:click={() => setOutputPanelMode('preview')}
              >
                Превью
              </button>
              <button
                class={`btn ${outputPanelMode === 'html' ? 'toggled' : ''}`}
                on:click={() => setOutputPanelMode('html')}
              >
                HTML
              </button>
              {#if outputPanelMode === 'pug'}
                <button id="copy-btn" class="btn btn-primary" on:click={handleCopy} title="Скопировать PUG">⧉</button>
                <button id="export-btn" class="btn btn-primary" on:click={downloadPug} title="Скачать PUG">↓</button>
              {:else if outputPanelMode === 'html'}
                <button class="btn btn-primary" on:click={(e) => renderEmailPreview(e.shiftKey)} disabled={previewLoading} title="Сгенерировать HTML (Shift+клик = без кеша)">
                  {previewLoading ? '⏳' : '🔄'}
                </button>
                <button class="btn btn-primary" on:click={handleCopyHtmlRaw} disabled={!previewHtml.trim()} title="Скопировать HTML как есть">⧉</button>
                <button class="btn btn-primary" on:click={handleCopyHtml} disabled={!previewHtml.trim()} title="Скопировать очищенный HTML">✦</button>
                <button class="btn btn-primary" on:click={downloadHtml} disabled={!previewHtml.trim()} title="Скачать HTML">↓</button>
              {:else}
                <button class="btn btn-primary" on:click={(e) => renderEmailPreview(e.shiftKey)} disabled={previewLoading} title="Сгенерировать HTML (Shift+клик = без кеша)">
                  {previewLoading ? '⏳' : '🔄'}
                </button>
                {#if previewHtml}
                  <button class="btn btn-primary" on:click={handleCopyHtml} title="Скопировать очищенный HTML">✦</button>
                  <button
                    class={`btn ${spellCheckActive ? 'toggled' : ''}`}
                    on:click={toggleSpellCheck}
                    disabled={spellLoading}
                    title="Подсветить орфографические ошибки"
                  >
                    {spellLoading ? '...' : spellCheckActive && spellErrors.length ? `АБВ (${spellErrors.length})` : 'АБВ'}
                  </button>
                  <button
                    class={`btn ${linkCheckActive ? 'toggled' : ''}`}
                    on:click={toggleLinkCheck}
                    disabled={linkCheckLoading}
                    title="Проверить ссылки и изображения"
                  >
                    {linkCheckLoading ? '...' : linkCheckActive && linkCheckResults.filter(r => !r.ok).length ? `🔗 (${linkCheckResults.filter(r => !r.ok).length})` : '🔗'}
                  </button>
                {/if}
              {/if}
            </div>
          </div>
          {#if linkCheckActive && linkCheckResults.length}
            <div class="link-check-panel">
              {#each linkCheckResults as result}
                <div class="link-check-item" class:link-ok={result.ok} class:link-error={!result.ok}>
                  <span class="link-status">{result.status || '???'}</span>
                  <a href={result.url} target="_blank" rel="noopener" class="link-url" title={result.url}>
                    {result.url.length > 60 ? result.url.slice(0, 60) + '...' : result.url}
                  </a>
                  {#if result.redirected}<span class="link-redirect" title={result.finalUrl}>→ редирект</span>{/if}
                  {#if result.error}<span class="link-error-msg">{result.error}</span>{/if}
                </div>
              {/each}
            </div>
          {/if}
          {#if outputPanelMode === 'pug'}
            <textarea id="pug-output" readonly value={outputPug} bind:this={pugOutputRef}></textarea>
          {:else if outputPanelMode === 'html'}
            <textarea id="html-output" readonly value={rawPreviewHtml || previewHtml}></textarea>
          {:else}
            <div class="preview-panel">
              {#if previewError}
                <div class="field-error">{previewError}</div>
              {/if}
              {#if unavailableProducts.length > 0}
                <div class="preview-warning">
                  <div>⚠ Нет в наличии ({unavailableProducts.length}):</div>
                  {#each unavailableProducts as prod}
                    <div class="unavailable-item">
                      <span class="unavailable-name">{prod.name || `ID ${prod.id}`}</span>
                      <button class="btn btn-xs" on:click={() => loadSuggestions(prod.id)}>Подобрать замену</button>
                    </div>
                  {/each}
                </div>
                {#if suggestForProduct}
                  <div class="suggest-panel">
                    <div class="suggest-header">
                      Замены для ID {suggestForProduct}
                      <button class="btn btn-xs" on:click={() => { suggestForProduct = null; suggestResults = []; suggestSearch = '' }}>×</button>
                    </div>
                    <div class="suggest-search">
                      <input
                        type="text"
                        placeholder="Поиск по названию или ID..."
                        bind:value={suggestSearch}
                        on:keydown={(e) => e.key === 'Enter' && loadSuggestions(suggestForProduct, suggestSearch)}
                      />
                      <button class="btn btn-xs" on:click={() => loadSuggestions(suggestForProduct, suggestSearch)}>Найти</button>
                    </div>
                    {#if suggestLoading}
                      <div class="suggest-loading">Загрузка...</div>
                    {:else if suggestResults.length === 0}
                      <div class="suggest-empty">Подходящих замен не найдено</div>
                    {:else}
                      {#each suggestResults as s}
                        <div class="suggest-item" on:click={() => applySuggestion(suggestForProduct, s.id)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && applySuggestion(suggestForProduct, s.id)}>
                          {#if s.image}<img src={s.image} alt="" class="suggest-img" />{/if}
                          <div class="suggest-info">
                            <div class="suggest-name">{s.name}</div>
                            <div class="suggest-price">{s.price ? `${Number(s.price).toLocaleString('ru-RU')} руб.` : ''}</div>
                          </div>
                          <div class="suggest-id">ID {s.id}</div>
                        </div>
                      {/each}
                    {/if}
                  </div>
                {/if}
              {/if}
              {#if previewStale && previewHtml}
                <div class="preview-meta">Изменения в PUG не сгенерированы, нажми 🔄.</div>
              {/if}
              {#if previewHtml}
                <div class="email-preview-viewport" class:preview-flash={previewFlash}>
                  <div
                    class="email-preview-scale-shell"
                    style={`width:${previewScaledWidth}px;height:${previewScaledHeight}px;`}
                  >
                    <iframe
                      bind:this={previewIframeRef}
                      on:load={handlePreviewFrameLoad}
                      class="email-preview-frame"
                      title="Превью письма"
                      srcdoc={spellCheckActive && spellerHtml ? spellerHtml : previewHtml}
                      style={`width:${previewContentWidth}px;height:${previewContentHeight}px;transform:scale(${previewScale});`}
                    ></iframe>
                  </div>
                </div>
              {:else if !previewLoading && !previewError}
                <div class="preview-empty">Нажми 🔄, чтобы собрать и увидеть письмо.</div>
              {/if}
            </div>
          {/if}
        </div>
      </div>
    {:else if activePage === 'settings'}
      <div class="panel-heading">
        <div>
          <div class="panel-title">Настройки</div>
          <div class="panel-subtitle">Проект, блоки и интеграции</div>
        </div>
      </div>

      <div class="panel-heading">
        <div class="tabs-actions">
          <button class={`btn ${settingsTab === 'project' ? 'toggled' : ''}`} on:click={() => (settingsTab = 'project')}>
            Проект
          </button>
          <button class={`btn ${settingsTab === 'blocks' ? 'toggled' : ''}`} on:click={() => (settingsTab = 'blocks')}>
            Блоки
          </button>
          <button class={`btn ${settingsTab === 'templates' ? 'toggled' : ''}`} on:click={() => (settingsTab = 'templates')}>
            Шаблоны
          </button>
          <button class={`btn ${settingsTab === 'integrations' ? 'toggled' : ''}`} on:click={() => (settingsTab = 'integrations')}>
            Интеграции
          </button>
          {#if currentUser?.role === 'admin'}
            <button class={`btn ${settingsTab === 'users' ? 'toggled' : ''}`} on:click={() => { settingsTab = 'users'; loadAdminUsers() }}>
              Пользователи
            </button>
            <button class={`btn ${settingsTab === 'logs' ? 'toggled' : ''}`} on:click={() => { settingsTab = 'logs'; auditLogsPage = 1; loadAuditLogs() }}>
              Логи
            </button>
          {/if}
        </div>
      </div>

      <div class="workspace-grid settings-grid">
        <div class="panel panel-left">
          {#if settingsTab === 'project'}
            <div class="settings-two-col">
              <div class="settings-col">
                <div class="settings-section">
                  <div class="settings-section-title">Текущий проект</div>
                  <div class="settings-section-desc">Настрой отображение и цвета для активного проекта.</div>
                  <label>
                    Логотип проекта
                    <div class="inline">
                      <label class="btn folder-btn">
                        Загрузить логотип
                        <input type="file" accept="image/*" on:change={handleLogoUpload} />
                      </label>
                      {#if settings.logoData}
                        <button type="button" class="btn ghost" on:click={removeLogo}>Удалить</button>
                      {/if}
                    </div>
                    <div class="hint">PNG, JPG или WEBP. Логотип хранится в настройках проекта.</div>
                  </label>
                  <label>
                    Название проекта в интерфейсе
                    <input
                      type="text"
                      class="field-control"
                      placeholder={currentProject}
                      value={settings.projectTitle || ''}
                      on:input={(event) => updateProjectTitle(event.target.value)}
                    />
                  </label>
                  <label>
                    Папка проекта в email-gen
                    <input
                      type="text"
                      class="field-control"
                      placeholder="Например: numizmat"
                      value={settings.emailGenProject || ''}
                      on:input={(event) => updateEmailGenProject(event.target.value)}
                    />
                    <div class="hint">Используется для генерации HTML-превью из email-gen.</div>
                  </label>
                  <label>
                    Цвет акцентов
                    <div class="inline">
                      <input
                        type="color"
                        class="color-input"
                        value={settings.accentColor || '#6b7280'}
                        on:input={(event) => updateAccentColor(event.target.value)}
                      />
                      <input
                        type="text"
                        class="field-control"
                        value={settings.accentColor || '#6b7280'}
                        on:input={(event) => updateAccentColor(event.target.value)}
                      />
                    </div>
                  </label>
                  <div class="settings-section-title" style="margin-top: 16px">Стиль гиперссылок</div>
                  <div class="settings-section-desc">Шаблон для кнопки «A» в текстовых полях. Используй <code>{`{url}`}</code> и <code>{`{text}`}</code> как плейсхолдеры.</div>
                  <label>
                    <textarea
                      class="field-control"
                      rows="3"
                      style="font-family: monospace; font-size: 11px"
                      placeholder={`<a href="{url}" target="_blank" class="bold greenText" style="text-decoration: none;">{text}</a>`}
                      value={settings.linkTemplate || ''}
                      on:input={(e) => saveSettings({ ...settings, linkTemplate: e.target.value })}
                    ></textarea>
                  </label>
                  <div class="hint">Пусто = стиль по умолчанию. Пример: <code>{`<a href="{url}" style="color: #AAC8C8; font-weight: bold;">{text}</a>`}</code></div>
                </div>
                <div class="settings-section">
                  <div class="settings-section-title">Источник блоков</div>
                  <div class="settings-section-desc">Загрузи файл block.pug для текущего проекта.</div>
                  <div class="inline">
                    <label class="btn folder-btn">
                      Загрузить block.pug
                      <input type="file" accept=".pug" on:change={handleFile} />
                    </label>
                    <button class="btn ghost" on:click={() => loadFromUrl(DEFAULT_SOURCE, 'Block.pug')}>
                      Перезагрузить
                    </button>
                  </div>
                  <div class="hint">Текущий файл: {sourceName}</div>
                </div>
              </div>
              <div class="settings-col">
                <div class="settings-section">
                  <div class="settings-section-title">Отступы</div>
                  <div class="settings-section-desc">Глобальный отступ применяется ко всем блокам, если не задано иначе.</div>
                  <label>
                    Глобальный отступ после блока
                    <input
                      type="number"
                      class="field-control"
                      value={settings.globalSpacing}
                      on:input={(event) => updateSettingsGlobalSpacing(event.target.value)}
                    />
                  </label>
                  <div class="hint">
                    Отступы не добавляются автоматически после блоков &laquo;Отступ 20&raquo; и &laquo;Отступ 40&raquo;.
                  </div>
                </div>
                <div class="settings-section">
                  <div class="settings-section-title">Быстрые блоки</div>
                  <div class="settings-section-desc">Настрой список кнопок быстрого добавления в конструкторе.</div>
                  <div class="inline">
                    <select
                      class="field-control"
                      bind:value={quickBlockCandidate}
                      bind:this={quickBlocksSelectRef}
                      on:change={(event) => (quickBlockCandidate = event.target.value)}
                    >
                      <option value="">Выбери блок</option>
                      {#each allBlocks as block}
                        {#if !block.isCustom}
                          <option value={block.name}>{block.name}</option>
                        {/if}
                      {/each}
                      {#if allBlocks.some(b => b.isCustom)}
                        <optgroup label="Пользовательские">
                          {#each allBlocks as block}
                            {#if block.isCustom}
                              <option value={block.name}>{block.name}</option>
                            {/if}
                          {/each}
                        </optgroup>
                      {/if}
                    </select>
                    <button
                      type="button"
                      class="btn"
                      on:click|preventDefault={() => addQuickBlockFromSettings(quickBlocksSelectRef?.value)}
                    >
                      Добавить
                    </button>
                  </div>
                  {#if quickBlocksError}
                    <div class="field-error">{quickBlocksError}</div>
                  {:else if quickBlocksStatus}
                    <div class="field-success">{quickBlocksStatus}</div>
                  {/if}
                  <div class="quick-blocks-list">
                    {#each quickBlocksView as quickName}
                      <span class="quick-block-pill">
                        {quickName}
                        <button
                          class="btn icon-square danger"
                          on:click|preventDefault={() => removeQuickBlockFromSettings(quickName)}
                          aria-label="Удалить быстрый блок"
                        >
                          ×
                        </button>
                      </span>
                    {/each}
                  </div>
                </div>
                <div class="settings-section">
                  <div class="settings-section-title">Новый проект</div>
                  <div class="settings-section-desc">Создай отдельный проект с собственными блоками и настройками.</div>
                  <div class="inline">
                    <input
                      type="text"
                      class="field-control"
                      placeholder="Название проекта"
                      bind:value={newProjectName}
                    />
                    <button class="btn" on:click={addProject}>Добавить</button>
                  </div>
                </div>
              </div>
            </div>
            <div class="settings-section">
              <div class="settings-section-title">Аккаунт</div>
              <div class="settings-section-desc">{currentUser?.name || currentUser?.login} ({currentUser?.role})</div>
              <button class="btn ghost danger" on:click={handleLogout}>Выйти из аккаунта</button>
            </div>
          {:else if settingsTab === 'blocks'}
            <div class="settings-section">
              <div class="settings-section-title">Поля блока</div>
              <div class="settings-section-desc">Выбери блок и настрой, какие поля показывать и как их подписывать.</div>
              <label>
                Блок
                <select class="field-control" bind:value={settingsBlockIndex}>
                  {#each allBlocks as block, index}
                    {#if !block.isCustom}
                      <option value={index}>{block.name}</option>
                    {/if}
                  {/each}
                  {#if allBlocks.some(b => b.isCustom)}
                    <optgroup label="Пользовательские">
                      {#each allBlocks as block, index}
                        {#if block.isCustom}
                          <option value={index}>{block.name}</option>
                        {/if}
                      {/each}
                    </optgroup>
                  {/if}
                </select>
              </label>
              <div class="block-body">
                {#if baseSchema.length === 0}
                  <p class="muted">Поля не найдены для текущего шаблона.</p>
                {:else}
                  {#each baseSchema as field, fieldIndex (fieldIndex)}
                    {@const sig = fieldSignature(field)}
                    <div class="inline">
                      <label>
                        <input
                          type="checkbox"
                          checked={getBlockSetting(settingsBlockName).fields?.[sig]?.enabled ?? true}
                          on:change={(event) =>
                            updateSettingsField(settingsBlockName, sig, { enabled: event.target.checked })
                          }
                        />
                        Показывать поле
                      </label>
                      <input
                        type="text"
                        class="field-control"
                        value={getBlockSetting(settingsBlockName).fields?.[sig]?.label ?? field.label}
                        on:input={(event) =>
                          updateSettingsField(settingsBlockName, sig, { label: event.target.value })
                        }
                      />
                    </div>
                  {/each}
                {/if}
              </div>
            </div>

            <div class="settings-section">
              <div class="settings-section-title">Опции товаров</div>
              <div class="settings-section-desc">Добавь опции, которые будут доступны в товарных блоках и в шапке.</div>
              <div class="product-options-settings">
                {#each settings.productOptions || [] as option, index}
                  <div class="product-option-row">
                    <input
                      class="field-control"
                      type="text"
                      placeholder="Название (например, ЦЕНА)"
                      value={option.label || ''}
                      on:input={(event) => updateProductOption(index, { label: event.target.value })}
                    />
                    <input
                      class="field-control"
                      type="text"
                      placeholder="Код (например, showPrices: true)"
                      value={option.code || ''}
                      on:input={(event) => updateProductOption(index, { code: event.target.value })}
                    />
                    <button class="btn icon-square danger" on:click={() => removeProductOption(index)} aria-label="Удалить опцию">
                      ×
                    </button>
                  </div>
                {/each}
                <button class="btn ghost" on:click={addProductOption}>Добавить опцию</button>
              </div>
            </div>

            <div class="settings-section">
              <div class="settings-section-title">Правила mixin-полей</div>
              <div class="settings-section-desc">Задай, какие аргументы mixin редактируются в конструкторе.</div>
              <div class="product-options-settings">
                {#each settings.mixinRules || [] as rule, index}
                  <div class="product-option-row">
                    <input
                      class="field-control"
                      type="text"
                      placeholder="Имя mixin (без +)"
                      value={rule.mixin || ''}
                      on:input={(event) => updateMixinRule(index, { mixin: event.target.value })}
                    />
                    <input
                      class="field-control tiny"
                      type="number"
                      min="1"
                      max="9"
                      value={(Number(rule.argIndex) || 0) + 1}
                      on:input={(event) => updateMixinRule(index, { argIndex: Number(event.target.value || 1) - 1 })}
                    />
                    <select
                      class="field-control"
                      value={rule.type || 'mixin-text'}
                      on:change={(event) => updateMixinRule(index, { type: event.target.value })}
                    >
                      <option value="mixin-text">Текст</option>
                      <option value="mixin-href">Ссылка</option>
                      <option value="mixin-ids">ID товаров</option>
                      <option value="mixin-opts">Опции (raw)</option>
                    </select>
                    <input
                      class="field-control"
                      type="text"
                      placeholder="Подпись поля (необязательно)"
                      value={rule.label || ''}
                      on:input={(event) => updateMixinRule(index, { label: event.target.value })}
                    />
                    <button class="btn icon-square danger" on:click={() => removeMixinRule(index)} aria-label="Удалить правило">
                      ×
                    </button>
                  </div>
                {/each}
                <button class="btn ghost" on:click={addMixinRule}>Добавить правило</button>
              </div>
            </div>

            <div class="settings-section">
              <div class="settings-section-title">Пользовательские блоки</div>
              <div class="settings-section-desc">Создай свои блоки из Pug-кода. Поля определяются автоматически.</div>
              {#each settings.customBlocks || [] as cb (cb.id)}
                <div class="block-card" style="margin-bottom:8px">
                  <div class="block-header">
                    {#if editingCustomBlockId === cb.id}
                      <input
                        type="text"
                        class="field-control"
                        value={cb.name}
                        on:change={(e) => updateCustomBlock(cb.id, { name: e.target.value })}
                      />
                    {:else}
                      <div class="block-title">{cb.name}</div>
                    {/if}
                    <div class="inline" style="gap:4px">
                      <button
                        class="btn icon-square"
                        title={editingCustomBlockId === cb.id ? 'Готово' : 'Редактировать'}
                        on:click={() => editingCustomBlockId = editingCustomBlockId === cb.id ? null : cb.id}
                      >
                        {editingCustomBlockId === cb.id ? '✓' : '✎'}
                      </button>
                      <button class="btn icon-square danger" title="Удалить блок" on:click={() => removeCustomBlock(cb.id)}>×</button>
                    </div>
                  </div>
                  {#if editingCustomBlockId === cb.id}
                    <CodeEditor
                      value={cb.content}
                      rows={6}
                      onchange={(v) => updateCustomBlock(cb.id, { content: v })}
                    />
                    {@const previewSchema = buildBaseSchema(cb.content, cb.name, settings?.mixinRules)}
                    {#if previewSchema.length > 0}
                      <div style="margin-top:8px">
                        <div class="muted" style="margin-bottom:4px">Полей: {previewSchema.length}</div>
                        {#each previewSchema as field, fi (fi)}
                          {@const sig = fieldSignature(field)}
                          <div class="inline" style="margin-bottom:2px">
                            <label>
                              <input
                                type="checkbox"
                                checked={getBlockSetting(cb.name).fields?.[sig]?.enabled ?? true}
                                on:change={(e) => updateSettingsField(cb.name, sig, { enabled: e.target.checked })}
                              />
                            </label>
                            <input
                              type="text"
                              class="field-control"
                              value={getBlockSetting(cb.name).fields?.[sig]?.label ?? field.label}
                              on:input={(e) => updateSettingsField(cb.name, sig, { label: e.target.value })}
                            />
                          </div>
                        {/each}
                      </div>
                    {:else}
                      <div class="muted" style="margin-top:4px">Поля не найдены</div>
                    {/if}
                  {/if}
                </div>
              {/each}
              <div style="margin-top:8px">
                <input
                  type="text"
                  class="field-control"
                  placeholder="Название блока"
                  bind:value={customBlockName}
                  style="margin-bottom:4px"
                />
                <CodeEditor
                  value={customBlockContent}
                  rows={4}
                  placeholder="Pug-код блока"
                  onchange={(v) => { customBlockContent = v }}
                />
                {#if customBlockError}
                  <div class="field-error">{customBlockError}</div>
                {/if}
                <button class="btn ghost" style="margin-top:4px" on:click={addCustomBlock}>Добавить блок</button>
              </div>
            </div>
          {:else if settingsTab === 'templates'}
            <div class="inline" style="margin-bottom:8px; gap:6px;">
              <label style="white-space:nowrap">
                <input
                  type="checkbox"
                  checked={allSpacingEnabled}
                  on:change={(e) => toggleAllBlocksSpacing(e.target.checked)}
                />
                Отступ после всех блоков
              </label>
              <div style="flex:1"></div>
              <button class="btn ghost" on:click={importBlocksPug}>Импорт .pug</button>
              <button class="btn ghost" on:click={downloadBlocksPug}>Скачать .pug</button>
              <button class="btn ghost" on:click={exportBlocksPug}>Сохранить .pug</button>
            </div>
            {#if allBlocks.length > 8}
              <input class="search-input" type="text" placeholder="Поиск блоков…" bind:value={templateSearch} />
            {/if}
            <div class="blocks-container">
              {#each filteredTemplateBlocks as block, index}
                <div class="block-card" class:collapsed={!templatesExpanded.has(block.name)}>
                  <div class="block-header">
                    <!-- svelte-ignore a11y-click-events-have-key-events -->
                    <div class="block-title clickable" on:click={() => toggleTemplateCard(block.name)}>
                      {index + 1}. {block.name}
                      {#if getBlockSetting(block.name).template}
                        <span class="template-override-dot" title="Шаблон изменён"></span>
                      {/if}
                      {#if block.isCustom}
                        <span class="template-custom-badge">свой</span>
                      {/if}
                    </div>
                  </div>
                  {#if templatesExpanded.has(block.name)}
                    <textarea
                      class="code"
                      rows={templateRows(block.name)}
                      value={getEffectiveTemplate(block.name)}
                      on:input={(event) => updateSettingsTemplate(block.name, event.target.value)}
                    ></textarea>
                    <div class="inline" style="margin-top:4px">
                      <label>
                        <input
                          type="checkbox"
                          checked={getBlockSetting(block.name).spacingEnabled ?? true}
                          on:change={(event) =>
                            updateSettingsBlock(block.name, { spacingEnabled: event.target.checked })
                          }
                        />
                        Отступ
                      </label>
                      {#if getBlockSetting(block.name).spacingEnabled ?? true}
                        <input
                          type="number"
                          class="field-control"
                          style="width:60px"
                          value={getBlockSetting(block.name).spacing ?? settings.globalSpacing}
                          on:input={(event) => updateSettingsBlockSpacing(block.name, event.target.value)}
                        />
                      {/if}
                      {#if getBlockSetting(block.name).template}
                        <button class="btn ghost" on:click={() => updateSettingsTemplate(block.name, '')}>Сбросить</button>
                      {/if}
                      <button class="btn ghost" on:click={() => duplicateBlockAsCustom(block.name)}>Копировать</button>
                      <button class="btn ghost" on:click={() => renameBlock(block)}>Переименовать</button>
                    </div>
                  {/if}
                </div>
              {/each}
              {#if filteredTemplateBlocks.length === 0}
                <div class="empty">Ничего не найдено</div>
              {/if}
            </div>
          {:else if settingsTab === 'integrations'}
            <div class="settings-section">
              <div class="settings-section-title">Google аккаунт</div>
              <div class="settings-section-desc">
                OAuth 2.0 credentials из Google Cloud Console (тип: Web application).<br />
                Redirect URI: <code>{window.location.origin}/oauth/callback</code>
              </div>
              <label>
                Client ID
                <input class="field-control" type="text" bind:value={sheetsClientIdDraft} placeholder="...apps.googleusercontent.com" />
              </label>
              <label>
                Client Secret
                <input class="field-control" type="password" bind:value={sheetsClientSecretDraft} placeholder={sheetsConfig.hasSecret ? '(сохранён)' : 'Введи Client Secret'} />
              </label>
              {#if sheetsConfigError}<div class="plan-error">{sheetsConfigError}</div>{/if}
              <div class="inline">
                <button class="btn" on:click={saveSheetsCredentials} disabled={sheetsSavingConfig}>{sheetsSavingConfig ? 'Сохраняю…' : 'Сохранить'}</button>
                {#if sheetsAuthStatus.configured}
                  <span class="plan-status-badge {sheetsAuthStatus.authorized ? 'ok' : 'warn'}">{sheetsAuthStatus.authorized ? 'Подключён' : 'Не авторизован'}</span>
                  <button class="btn ghost" on:click={openOAuthPopup}>Подключить аккаунт</button>
                {/if}
              </div>
            </div>
            <div class="settings-section">
              <div class="settings-section-title">Таблица для «{currentProject}»</div>
              <div class="settings-section-desc">Укажи таблицу и колонки для страницы «План». Spreadsheet ID — из URL таблицы.</div>
              <label>Spreadsheet ID<input class="field-control" type="text" bind:value={sheetsCfgSpreadsheetId} /></label>
              <label>Название листа (вкладка)<input class="field-control" type="text" bind:value={sheetsCfgSheetName} placeholder="2026" /></label>
              <div class="inline">
                <label>Дата<input class="field-control inline-col" type="text" bind:value={sheetsCfgDateCol} maxlength="2" /></label>
                <label>Тема<input class="field-control inline-col" type="text" bind:value={sheetsCfgSubjectCol} maxlength="2" /></label>
                <label>Прехедер<input class="field-control inline-col" type="text" bind:value={sheetsCfgPreheaderCol} maxlength="2" /></label>
                <label>Статус<input class="field-control inline-col" type="text" bind:value={sheetsCfgStatusCol} maxlength="2" /></label>
                <label>Пропустить строк<input class="field-control inline-col" type="number" bind:value={sheetsCfgHeaderRows} min="0" max="10" /></label>
              </div>
              <label>
                Фильтр статусов (через запятую)
                <input class="field-control" type="text" bind:value={sheetsCfgStatusFilter} placeholder="передано в сборку, готово" />
              </label>
              <div class="sheets-extra-cols">
                <div class="settings-section-desc" style="margin-top:8px">Дополнительные столбцы (показываются в карточке плана)</div>
                {#each sheetsCfgExtraCols as ec, i}
                  <div class="sheets-extra-col-row">
                    <input class="field-control inline-col" type="text" bind:value={ec.col} maxlength="2" placeholder="B" style="width:40px" />
                    <input class="field-control" type="text" bind:value={ec.label} placeholder="Название" style="flex:1" />
                    <label class="sheets-extra-vis"><input type="checkbox" bind:checked={ec.visible} /> вкл</label>
                    <button class="btn ghost btn-sm" on:click={() => { sheetsCfgExtraCols = sheetsCfgExtraCols.filter((_, j) => j !== i) }}>✕</button>
                  </div>
                {/each}
                <button class="btn ghost btn-sm" on:click={() => { sheetsCfgExtraCols = [...sheetsCfgExtraCols, { col: '', label: '', visible: true }] }}>+ столбец</button>
              </div>
              <div class="inline">
                <button class="btn" on:click={saveSheetsProjectConfig}>Сохранить</button>
                <button class="btn ghost" on:click={testSheetsConnection} disabled={sheetsTestLoading}>{sheetsTestLoading ? 'Проверяю…' : 'Проверить подключение'}</button>
              </div>
              {#if sheetsTestResult}
                {@const isOk = sheetsTestResult.startsWith('ok:')}
                <div class="plan-status-badge {isOk ? 'ok' : 'error'}" style="margin-top:8px;display:inline-block;">{sheetsTestResult.replace(/^(ok|error):/, '')}</div>
              {/if}
            </div>
            <div class="settings-section">
              <div class="settings-section-title">Хранение картинок для «{currentProject}»</div>
              <div class="settings-section-desc">Выберите где хранить загруженные изображения.</div>
              <label>
                Способ хранения
                <select class="field-control" value={settings.imageStorage || 'ftp'} on:change={(e) => saveSettings({ ...settings, imageStorage: e.target.value })}>
                  <option value="ftp">FTP/SFTP (внешний сервер)</option>
                  <option value="local">Локально (на этом сервере)</option>
                </select>
              </label>
              {#if (settings.imageStorage || 'ftp') === 'local'}
                <div class="hint" style="margin-top:8px">Картинки сохраняются на сервере и доступны по ссылке:<br/><code>{typeof window !== 'undefined' ? window.location.origin : ''}/images/{currentProject}/{getLetterDateFolder() || 'DD-MM'}/имя.png</code></div>
              {:else}
              <div class="settings-section-title" style="margin-top:16px">FTP/SFTP настройки</div>
              <div class="settings-section-desc">Папка по дате письма создаётся автоматически.</div>
              <label>
                Протокол
                <select class="field-control" bind:value={ftpProtocol}>
                  <option value="ftp">FTP</option>
                  <option value="sftp">SFTP</option>
                </select>
              </label>
              <div class="inline" style="gap:8px">
                <label style="flex:1">
                  Хост
                  <input class="field-control" type="text" bind:value={ftpHost} placeholder="ftp.example.com" />
                </label>
                <label style="width:80px">
                  Порт
                  <input class="field-control" type="text" bind:value={ftpPort} placeholder={ftpProtocol === 'sftp' ? '22' : '21'} />
                </label>
              </div>
              <div class="inline" style="gap:8px">
                <label style="flex:1">
                  Логин
                  <input class="field-control" type="text" bind:value={ftpUser} />
                </label>
                <label style="flex:1">
                  Пароль
                  <input class="field-control" type="text" bind:value={ftpPassword}
                    placeholder={settings.ftpConfig?.hasPassword ? '(сохранён)' : ''} />
                </label>
              </div>
              <label>
                Удалённый путь (базовая папка на сервере)
                <input class="field-control" type="text" bind:value={ftpRemotePath} placeholder="/upload/email/newsletter-2026" />
              </label>
              <label>
                Публичный URL (без / в конце)
                <input class="field-control" type="text" bind:value={ftpBaseUrl} placeholder="https://z51.ru/upload/email/newsletter-2026" />
              </label>
              <div class="hint">Результат: {ftpBaseUrl || 'https://...'}/{getLetterDateFolder() || 'DD-MM'}/имя.png</div>
              <div class="inline" style="gap:8px;margin-top:4px">
                <button class="btn" on:click={saveFtpConfig}>Сохранить</button>
                <button class="btn ghost" on:click={testFtpConnection} disabled={ftpTestLoading}>
                  {ftpTestLoading ? 'Проверяю…' : 'Проверить'}
                </button>
              </div>
              {#if ftpTestResult}
                {@const isOk = ftpTestResult.startsWith('ok:')}
                <div class="plan-status-badge {isOk ? 'ok' : 'error'}" style="margin-top:6px;display:inline-block">
                  {ftpTestResult.replace(/^(ok|error):/, '')}
                </div>
              {/if}
              {/if}
            </div>
            <div class="settings-section">
              <div class="settings-section-title">Сервис рассылок</div>
              <div class="settings-section-desc">Кнопка под карточкой сборки для перехода в сервис</div>
              <label>Текст кнопки
                <input class="field-control" type="text" bind:value={mailingServiceLabel} placeholder="Создать рассылку" />
              </label>
              <label>Ссылка
                <input class="field-control" type="text" bind:value={mailingServiceUrl} placeholder="https://..." />
              </label>
              <div class="inline" style="gap:8px;margin-top:8px">
                <button class="btn" on:click={saveMailingService}>Сохранить</button>
              </div>
            </div>

            <div class="settings-section">
              <div class="settings-section-title">Фид товаров</div>
              <div class="settings-section-desc">URL YML-фида каталога. Товары подставятся в превью вместо Mindbox-тегов.</div>
              <label>URL фида
                <input class="field-control" type="text" bind:value={feedUrlDraft} placeholder="https://site.ru/bitrix/catalog_export/mindbox.php" />
              </label>
              <div class="inline" style="gap:8px;margin-top:8px">
                <button class="btn" on:click={saveFeedUrl}>Сохранить</button>
                {#if feedUrlDraft.trim()}
                  <button class="btn ghost" on:click={refreshFeed} disabled={feedRefreshLoading}>
                    {feedRefreshLoading ? '⏳ Загрузка фида…' : '🔄 Обновить фид'}
                  </button>
                {/if}
              </div>
              {#if feedRefreshResult}
                {@const isOk = feedRefreshResult.startsWith('ok:')}
                <div class="plan-status-badge {isOk ? 'ok' : 'error'}" style="margin-top:6px;display:inline-block">
                  {feedRefreshResult.replace(/^(ok|error):/, '')}
                </div>
              {/if}
              {#if feedRefreshDiff?.addedProducts?.length}
                <div class="feed-diff-list" style="margin-top:8px">
                  <div class="muted" style="margin-bottom:4px">Новые товары:</div>
                  {#each feedRefreshDiff.addedProducts as p}
                    <div class="feed-diff-item">
                      <span class="feed-diff-id">{p.id}</span>
                      <span class="feed-diff-name">{p.name}</span>
                    </div>
                  {/each}
                  {#if feedRefreshDiff.added > feedRefreshDiff.addedProducts.length}
                    <div class="muted">…и ещё {feedRefreshDiff.added - feedRefreshDiff.addedProducts.length}</div>
                  {/if}
                </div>
              {/if}
            </div>

            <div class="settings-section">
              <div class="settings-section-title">RetailCRM каталог</div>
              <div class="settings-section-desc">Товары из RetailCRM вместо YML-фида. Поиск и подстановка работают через API.</div>
              <label>URL RetailCRM
                <input class="field-control" type="text" bind:value={retailcrmUrlDraft} placeholder="https://yourshop.retailcrm.ru" />
              </label>
              <label style="margin-top:6px">API-ключ
                <input class="field-control" type="text" bind:value={retailcrmApiKeyDraft} placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
              </label>
              <div class="inline" style="gap:8px;margin-top:8px">
                <button class="btn" on:click={saveRetailcrm}>Сохранить</button>
                {#if settings?.retailcrmConfig?.url}
                  <button class="btn ghost" on:click={refreshFeed} disabled={feedRefreshLoading}>
                    {feedRefreshLoading ? '⏳ Проверка…' : '🔄 Проверить соединение'}
                  </button>
                {/if}
              </div>
              {#if feedRefreshResult}
                {@const isOk = feedRefreshResult.startsWith('ok:')}
                <div class="plan-status-badge {isOk ? 'ok' : 'error'}" style="margin-top:6px;display:inline-block">
                  {feedRefreshResult.replace(/^(ok|error):/, '')}
                </div>
              {/if}
            </div>
          {:else if settingsTab === 'users' && currentUser?.role === 'admin'}
            <div class="admin-page">
              {#if adminLoading}
                <div class="preview-empty">Загрузка...</div>
              {:else}
                {#if adminError}<div class="field-error" style="margin-bottom:8px">{adminError}</div>{/if}
                <table class="stats-table">
                  <thead>
                    <tr>
                      <th>Логин</th>
                      <th>Имя</th>
                      <th>Роль</th>
                      <th>Проекты</th>
                      <th></th>
                    </tr>
                  </thead>
                  <tbody>
                    {#each adminUsers as user}
                      <tr>
                        <td><strong>{user.login}</strong></td>
                        <td>{user.name}</td>
                        <td>
                          <select value={user.role} on:change={(e) => adminUpdateUser(user.id, { role: e.target.value })}>
                            <option value="admin">admin</option>
                            <option value="user">user</option>
                          </select>
                        </td>
                        <td class="admin-projects-cell">
                          {#if user.projects?.includes('*')}
                            <span class="admin-all-projects">Все проекты</span>
                          {:else}
                            {#each user.projects || [] as proj}
                              <span class="admin-project-tag">
                                {proj}
                                <button class="admin-tag-remove" on:click={() => adminUpdateUser(user.id, { projects: user.projects.filter(p => p !== proj) })}>×</button>
                              </span>
                            {/each}
                          {/if}
                          <span class="admin-add-project">
                            <select on:change={(e) => { if (e.target.value) { adminUpdateUser(user.id, { projects: [...(user.projects || []).filter(p => p !== '*'), e.target.value] }); e.target.value = '' } }}>
                              <option value="">+</option>
                              <option value="*">Все</option>
                              {#each projects as p}
                                <option value={p}>{p}</option>
                              {/each}
                            </select>
                          </span>
                        </td>
                        <td>
                          {#if user.id !== currentUser.id}
                            <button class="btn ghost danger" on:click={() => adminDeleteUser(user.id)} title="Удалить">×</button>
                          {/if}
                        </td>
                      </tr>
                    {/each}
                  </tbody>
                </table>
                <div class="admin-new-user">
                  <div class="admin-new-title">Новый пользователь</div>
                  <div class="admin-new-fields">
                    <input class="field-control" placeholder="Логин" bind:value={adminNewUser.login} />
                    <input class="field-control" placeholder="Пароль" type="password" bind:value={adminNewUser.password} />
                    <input class="field-control" placeholder="Имя" bind:value={adminNewUser.name} />
                    <select class="field-control" bind:value={adminNewUser.role}>
                      <option value="user">user</option>
                      <option value="admin">admin</option>
                    </select>
                    <button class="btn btn-primary" on:click={adminCreateUser}>Создать</button>
                  </div>
                </div>
              {/if}
            </div>
          {:else if settingsTab === 'logs' && currentUser?.role === 'admin'}
            <div class="admin-page">
              <div class="audit-filters" style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
                <input class="field-control" placeholder="Пользователь" style="width:120px" bind:value={auditFilterUser} on:change={() => { auditLogsPage = 1; loadAuditLogs() }} />
                <input class="field-control" placeholder="Проект" style="width:120px" bind:value={auditFilterProject} on:change={() => { auditLogsPage = 1; loadAuditLogs() }} />
                <input class="field-control" placeholder="Действие" style="width:140px" bind:value={auditFilterAction} on:change={() => { auditLogsPage = 1; loadAuditLogs() }} />
                <button class="btn ghost" on:click={() => { auditFilterUser = ''; auditFilterProject = ''; auditFilterAction = ''; auditLogsPage = 1; loadAuditLogs() }}>Сбросить</button>
                <span class="muted" style="align-self:center;font-size:12px">{auditLogsTotal} записей</span>
              </div>
              {#if auditLogsLoading}
                <div class="muted">Загрузка…</div>
              {:else if !auditLogs.length}
                <div class="muted">Нет записей</div>
              {:else}
                <table class="admin-table" style="font-size:12px">
                  <thead>
                    <tr>
                      <th>Время</th>
                      <th>Пользователь</th>
                      <th>Действие</th>
                      <th>Проект</th>
                    </tr>
                  </thead>
                  <tbody>
                    {#each auditLogs as log}
                      <tr>
                        <td style="white-space:nowrap">{new Date(log.ts).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
                        <td>{log.login || '—'}</td>
                        <td>{log.action}</td>
                        <td>{log.project || ''}</td>
                      </tr>
                    {/each}
                  </tbody>
                </table>
                {#if auditLogsTotal > 50}
                  <div style="display:flex;gap:8px;margin-top:8px;align-items:center">
                    <button class="btn ghost" disabled={auditLogsPage <= 1} on:click={() => { auditLogsPage--; loadAuditLogs() }}>←</button>
                    <span class="muted" style="font-size:12px">Стр. {auditLogsPage} / {Math.ceil(auditLogsTotal / 50)}</span>
                    <button class="btn ghost" disabled={auditLogsPage * 50 >= auditLogsTotal} on:click={() => { auditLogsPage++; loadAuditLogs() }}>→</button>
                  </div>
                {/if}
              {/if}
            </div>
          {/if}

        </div>
      </div>
    {:else if activePage === 'plan'}
      <div class="panel-heading">
        <div>
          <div class="panel-title">План</div>
          <div class="panel-subtitle">Ближайшие рассылки из всех проектов</div>
        </div>
        <div class="inline">
          {#if planLoadedAt}<span class="plan-loaded-at">Обновлено {planLoadedAt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}</span>{/if}
          <button class="btn ghost" class:toggled={planShowDone} on:click={() => planShowDone = !planShowDone}>
            {planShowDone ? 'Скрыть выполненные' : 'Показать выполненные'}
          </button>
          <button class="btn ghost" on:click={() => loadPlan()} disabled={planLoading}>{planLoading ? 'Загрузка…' : 'Обновить'}</button>
        </div>
      </div>
      <div class="plan-page-grid">
        <div class="panel plan-list-panel">
          {#if planLoading && planRows.length === 0}
            <div class="plan-state">Загружаю данные из таблиц…</div>
          {:else if planError}
            <div class="plan-state plan-state-error">{planError}</div>
          {:else if planRows.length === 0}
            <div class="plan-state">Нет рассылок в диапазоне −7 / +60 дней, либо таблицы не настроены (Настройки → Интеграции).</div>
            {#if planWarnings.length > 0}
              <div class="plan-warnings">{#each planWarnings as w}<div class="plan-warning">⚠ {w}</div>{/each}</div>
            {/if}
          {:else}
            {#if planWarnings.length > 0}
              <div class="plan-warnings">{#each planWarnings as w}<div class="plan-warning">⚠ {w}</div>{/each}</div>
            {/if}
            <div class="plan-periods">
              {#each planPeriodGroups as period}
                <div class="plan-period plan-period-{period.accent}">
                  <div class="plan-period-label">{period.label}</div>
                  <div class="plan-groups">
                    {#each period.groups as dayGroup}
                      <div class="plan-group" id="plan-day-{dayGroup.isoStr}" class:plan-group-selected={planCalSelectedDate === dayGroup.isoStr}>
                        <div class="plan-day-header">
                          <span class="plan-day-num">{String(dayGroup.date.getDate()).padStart(2, '0')}</span>
                          <div class="plan-day-label">
                            <span class="plan-day-month">{dayGroup.date.toLocaleString('ru-RU', { month: 'long' })}</span>
                            <span class="plan-day-dow">{dayGroup.date.toLocaleString('ru-RU', { weekday: 'short' })}</span>
                          </div>
                          <span class="plan-group-count">{dayGroup.rows.length}</span>
                        </div>
                        {#each dayGroup.rows as row}
                          <div class="plan-item" class:plan-item-selected={planCalSelectedDate === dayGroup.isoStr}>
                            <span class="plan-proj-badge">{row.project}</span>
                            <div class="plan-item-dot plan-dot-{planStatusClass(row.status)}"></div>
                            <div class="plan-item-content">
                              <div class="plan-item-subject">{row.subject}</div>
                              <div class="plan-item-meta">
                                {#if planEditingRowId === planRowId(row)}
                                  <select
                                    class="plan-status-select"
                                    value={row.status}
                                    disabled={planSavingRowId === planRowId(row)}
                                    on:change={(e) => setPlanRowStatus(row, e.target.value)}
                                    on:blur={() => { if (planEditingRowId === planRowId(row)) planEditingRowId = null }}
                                  >
                                    {#each planStatusOptions as opt}
                                      <option value={opt}>{opt}</option>
                                    {/each}
                                    {#if !planStatusOptions.includes(row.status)}
                                      <option value={row.status}>{row.status}</option>
                                    {/if}
                                  </select>
                                  {#if planSavingRowId === planRowId(row)}<span class="plan-item-sep">сохранение…</span>{/if}
                                {:else}
                                  {#if row.status}
                                    <span
                                      class="plan-item-status plan-dot-{planStatusClass(row.status)} plan-status-editable"
                                      title="Нажми чтобы изменить статус"
                                      on:click={() => planEditingRowId = planRowId(row)}
                                    >{row.status}</span>
                                  {:else}
                                    <span class="plan-status-empty" on:click={() => planEditingRowId = planRowId(row)}>+ статус</span>
                                  {/if}
                                {/if}
                                {#if row.preheader && row.status}<span class="plan-item-sep">·</span>{/if}
                                {#if row.preheader}<span class="plan-item-preheader">{row.preheader.length > 70 ? row.preheader.slice(0, 70) + '…' : row.preheader}</span>{/if}
                                {#each (row.extra || []).filter(e => e.link || isUrl(e.value)) as ext}
                                  <a class="plan-extra-link" href={ext.link || ext.value} target="_blank" rel="noopener" title={ext.label}>{ext.label}</a>
                                {/each}
                              </div>
                              {#if (row.extra || []).some(e => !e.link && !isUrl(e.value))}
                                <div class="plan-item-extra">
                                  {#each (row.extra || []).filter(e => !e.link && !isUrl(e.value)) as ext}
                                    <span class="plan-extra-tag" class:plan-extra-highlight={/категори|тематик|тип/i.test(ext.label)} title={ext.label}>{ext.value}</span>
                                  {/each}
                                </div>
                              {/if}
                            </div>
                            <button class="plan-item-btn" on:click={() => startAssemble(row)}>Собирать →</button>
                          </div>
                        {/each}
                      </div>
                    {/each}
                  </div>
                </div>
              {/each}
            </div>
          {/if}
        </div>

        <div class="plan-cal-panel">
          <div class="plan-cal-nav">
            <button class="btn ghost icon-square" on:click={prevPlanMonth}>‹</button>
            <span class="plan-cal-title">{planCalMonth.toLocaleString('ru-RU', { month: 'long', year: 'numeric' })}</span>
            <button class="btn ghost icon-square" on:click={nextPlanMonth}>›</button>
          </div>
          <div class="plan-cal-grid">
            {#each ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'] as dow}
              <div class="plan-cal-dow">{dow}</div>
            {/each}
            {#each planCalDays as day}
              <div
                class="plan-cal-day"
                class:plan-cal-other={!day.current}
                class:plan-cal-today={day.isoStr === planTodayIso}
                class:plan-cal-selected={planCalSelectedDate === day.isoStr}
                class:plan-cal-has={day.rows.length > 0}
                on:click={() => selectPlanDate(day.isoStr)}
              >
                <span class="plan-cal-num">{day.date.getDate()}</span>
                {#if day.rows.length > 0}
                  <div class="plan-cal-dots">
                    {#each day.rows.slice(0, 4) as row}
                      <span class="plan-cal-dot plan-dot-{planStatusClass(row.status)}"></span>
                    {/each}
                  </div>
                {/if}
              </div>
            {/each}
          </div>
        </div>
      </div>
    {:else if activePage === 'stats'}
      <div class="panel-heading">
        <div>
          <div class="panel-title">Статистика</div>
          <div class="panel-subtitle">Время работы над письмами</div>
        </div>
      </div>
      <div class="stats-page">
        {#if statsPageLoading}
          <div class="preview-empty">Загрузка...</div>
        {:else if Object.keys(allStats).length === 0}
          <div class="preview-empty">Нет данных</div>
        {:else}
          {#each statsMonthGroups as [monthKey, entries]}
            {@const [y, m] = monthKey.split('-')}
            {@const monthLabel = new Date(Number(y), Number(m) - 1).toLocaleString('ru-RU', { month: 'long', year: 'numeric' })}
            {@const totalMin = entries.reduce((s, e) => s + (e.minutes || 0), 0)}
            <div class="stats-month">
              <div class="stats-month-header">
                <span class="stats-month-label">{monthLabel}</span>
                <span class="stats-month-total">{entries.length} писем, {Math.floor(totalMin / 60)}ч {totalMin % 60}м</span>
              </div>
              <table class="stats-table">
                <thead>
                  <tr>
                    <th>Проект</th>
                    <th>Письмо</th>
                    <th>Тег</th>
                    <th>Время</th>
                    <th>Статус</th>
                  </tr>
                </thead>
                <tbody>
                  {#each entries.sort((a, b) => (b.startedAt || '').localeCompare(a.startedAt || '')) as entry}
                    <tr>
                      <td class="stats-project">{entry.project}</td>
                      <td class="stats-letter">{entry.letterName}</td>
                      <td class="stats-tag">{entry.tag || ''}</td>
                      <td class="stats-time">{entry.minutes || 0} мин</td>
                      <td class="stats-status">{entry.status || ''}</td>
                    </tr>
                  {/each}
                </tbody>
              </table>
            </div>
          {/each}
        {/if}
      </div>
    {:else}
      <div class="panel-heading">
        <div>
          <div class="panel-title">Письма</div>
          <div class="panel-subtitle">Сохранённые письма</div>
        </div>
      </div>

      <div class="panel panel-left">
        {#if presets.length > 0}
          <div class="panel-subsection">
            <div class="panel-subtitle">Пресеты</div>
            <div class="preset-quick-list">
              {#each presets as preset}
                <div class="preset-chip">
                  <button class="btn ghost" on:click={() => applyPresetBlocks(preset)}>
                    {preset.name}
                  </button>
                  <button
                    class="btn icon-square danger"
                    on:click={() => deletePresetById(preset.id)}
                    title="Удалить пресет"
                    aria-label="Удалить пресет"
                  >
                    ×
                  </button>
                </div>
              {/each}
            </div>
          </div>
        {/if}
        <div class="inline" style="gap:8px;margin-bottom:8px">
          {#if letters.length > 8}
            <input class="search-input" type="text" placeholder="Поиск писем…" bind:value={letterSearch} style="flex:1" />
          {/if}
          <button class="btn ghost" style="font-size:11px;white-space:nowrap" on:click={() => { showImportPlanModal = true; importPlanStep = 'paste'; importPlanText = ''; importPlanParsed = []; importPlanError = ''; importPlanProgress = '' }}>
            📋 Импорт из ТЗ
          </button>
        </div>
        {#if filteredLetters.length === 0}
          <div class="empty">{letterSearch ? 'Ничего не найдено.' : 'Писем пока нет.'}</div>
        {:else}
          <div class="letters-list">
            {#each filteredLetters as letter}
              <div class="letter-row" class:history-open={showHistoryFor?.id === letter.id}>
                <div class="letter-row-main">
                  <div class="letter-row-name">{letter.name}</div>
                  <div class="letter-row-date">
                    {formatPresetDate(letter.updatedAt || letter.createdAt)}{#if letter.createdBy}<span class="letter-author" title="Создал: {letter.createdBy}{letter.updatedBy && letter.updatedBy !== letter.createdBy ? ', изменил: ' + letter.updatedBy : ''}"> · {letter.createdBy}</span>{/if}
                  </div>
                </div>
                <div class="letter-row-actions">
                  <button class="btn ghost" on:click={() => duplicateLetter(letter)}>Копировать</button>
                  <button class="btn ghost" on:click={() => openLetter(letter)}>Открыть</button>
                  <button class="btn ghost" on:click={() => openHistory(letter)}>История</button>
                  <button class="btn icon-square danger" on:click={() => deleteLetter(letter)}>×</button>
                </div>
                {#if showHistoryFor?.id === letter.id}
                  <div class="history-dropdown">
                    {#if historyLoading}
                      <div class="history-item muted">Загрузка…</div>
                    {:else if historySnapshots.length === 0}
                      <div class="history-item muted">История пуста</div>
                    {:else}
                      {#each historySnapshots as snap}
                        <button class="history-item" on:click={() => restoreFromHistory(snap)}>
                          {new Date(snap.savedAt).toLocaleString('ru')}
                        </button>
                      {/each}
                    {/if}
                  </div>
                {/if}
              </div>
            {/each}
          </div>
        {/if}
      </div>
    {/if}
  </div>

  {#if showLetterModal}
    <div class="modal-backdrop" on:click|self={() => (showLetterModal = false)}>
      <div class="modal-card">
        <div class="modal-title">
          {letterModalMode === 'update' ? 'Сохранить письмо' : 'Новое письмо'}
        </div>
        <div class="modal-step">Шаг 1. Дата отправки</div>
        <input class="field-control" type="date" bind:value={newLetterDate} />
        <div class="modal-step">Шаг 2. Название письма</div>
        <input
          class="field-control"
          type="text"
          placeholder="Название письма"
          bind:value={newLetterTitle}
        />
        <div class="modal-preview">
          Итоговое имя: {buildLetterName(newLetterTitle, newLetterDate)}
        </div>
        {#if newLetterError}
          <div class="modal-error">{newLetterError}</div>
        {/if}
        <div class="modal-actions">
          <button class="btn ghost" on:click={() => (showLetterModal = false)}>Отмена</button>
          <button class="btn" on:click={saveLetterFromModal}>
            {letterModalMode === 'update' ? 'Сохранить' : 'Создать письмо'}
          </button>
        </div>
      </div>
    </div>
  {/if}

  {#if showPresetModal}
    <div class="modal-backdrop" on:click|self={() => (showPresetModal = false)}>
      <div class="modal-card">
        <div class="modal-title">Новый пресет</div>
        <div class="modal-step">Короткое имя пресета</div>
        <input
          class="field-control"
          type="text"
          placeholder="Название пресета"
          bind:value={presetNameDraft}
          on:keydown={(event) => event.key === 'Enter' && createPresetFromLetter()}
        />
        {#if presetError}
          <div class="modal-error">{presetError}</div>
        {/if}
        <div class="modal-actions">
          <button class="btn ghost" on:click={() => (showPresetModal = false)}>Отмена</button>
          <button class="btn" on:click={createPresetFromLetter}>Создать</button>
        </div>
      </div>
    </div>
  {/if}

  {#if showStartupModal}
    <div class="modal-backdrop">
      <div class="modal-card">
        <div class="modal-title">{currentProject}</div>
        {#if startupLastLetter}
          <div class="modal-step">Последнее письмо</div>
          <button
            class="btn startup-last-letter-btn"
            on:click={() => { showStartupModal = false; openLetter(startupLastLetter) }}
          >
            {startupLastLetter.name}
          </button>
          <div class="modal-divider">или</div>
        {/if}
        <button class="btn" on:click={() => { showStartupModal = false; openLetterModal('new') }}>
          Создать новое письмо
        </button>
        {#if startupPlanRows.length > 0}
          <div class="modal-divider">рассылки из плана</div>
          <div class="startup-plan-list">
            {#each startupPlanRows as row}
              {@const isPast = row.date && row.date < new Date(new Date().setHours(0,0,0,0))}
              {@const isDone = planStatusClass(row.status) === 'done'}
              {#if !isPast || !isDone}
              {@const overdue = isPast && !isDone}
              <button
                class="btn ghost startup-plan-btn"
                class:startup-plan-overdue={overdue}
                on:click={() => { showStartupModal = false; startAssemble(row) }}
              >
                <span class="startup-plan-date">{row.dateStr}</span>
                <span class="startup-plan-subject">{row.subject}</span>
                {#if row.status}<span class="startup-plan-status plan-status-{planStatusClass(row.status)}">{row.status}</span>{/if}
              </button>
              {/if}
            {/each}
          </div>
        {/if}
        <div class="modal-actions">
          <button class="btn ghost" on:click={() => (showStartupModal = false)}>
            Продолжить без письма
          </button>
        </div>
      </div>
    </div>
  {/if}

  {#if showFtpGalleryModal}
    <div class="modal-backdrop" on:click|self={() => (showFtpGalleryModal = false)}>
      <div class="modal-card ftp-modal">
        <div class="modal-title">
          FTP: {settings.ftpConfig?.host}
          <span style="color:var(--muted);font-weight:400"> / {ftpGalleryFolder}</span>
        </div>
        <div class="ftp-modal-path">{settings.ftpConfig?.remotePath}/{ftpGalleryFolder}/</div>
        <div class="ftp-upload-form">
          <div class="ftp-upload-row">
            <label class="btn ghost ftp-file-btn">
              {ftpUploading ? ftpUploadStatus : '+ Загрузить картинки'}
              <input type="file" accept="image/png,image/jpeg,image/gif,image/webp"
                multiple style="display:none"
                disabled={ftpUploading} on:change={handleFtpFileSelect} />
            </label>
          </div>
          {#if ftpUploadStatus}<div class="ftp-upload-status">{ftpUploadStatus}</div>{/if}
          {#if ftpUploadError}<div class="plan-error" style="margin-top:4px;white-space:pre-wrap">{ftpUploadError}</div>{/if}
        </div>
        {#if ftpGalleryLoading}
          <div class="ftp-gallery-state">Загружаю список…</div>
        {:else if ftpGalleryError}
          <div class="ftp-gallery-state ftp-gallery-error">{ftpGalleryError}</div>
        {:else if ftpGalleryImages.length === 0}
          <div class="ftp-gallery-state">Папка пуста</div>
        {:else}
          <div class="ftp-gallery-grid">
            {#each ftpGalleryImages as img}
              <div class="ftp-gallery-item" on:click={() => selectFtpImage(img.url)} title={img.url}>
                <img src={img.url} alt={img.name}
                  on:error={(e) => { e.target.style.display = 'none' }} />
                <div class="ftp-gallery-item-name">{img.name}</div>
              </div>
            {/each}
          </div>
        {/if}
        <div class="modal-actions">
          <button class="btn ghost" on:click={loadFtpGallery} disabled={ftpGalleryLoading}>Обновить</button>
          <button class="btn ghost" on:click={() => (showFtpGalleryModal = false)}>Закрыть</button>
        </div>
      </div>
    </div>
  {/if}

  {#if tzModalBlockId !== null}
    <div class="modal-backdrop" on:click|self={() => (tzModalBlockId = null)}>
      <div class="modal-card" style="width:1100px;max-width:96vw">
        <div class="modal-title">📋 Вставить из ТЗ</div>
        {#if !tzModalResults.length}
          <div class="modal-step">Вставьте текст ТЗ с товарами</div>
          <textarea
            class="field-control"
            rows="3"
            placeholder={"Кресло CITADEL Royal 34 990 P (Цитадель на колесах)\nhttps://z51.ru/catalog/...\n\nКресло BUNNY 25 990 → 19 493 (-25%) (Очень хороший кролик)\nhttps://z51.ru/catalog/..."}
            bind:value={tzModalText}
            style="font-size:12px;font-family:monospace;overflow:hidden;resize:vertical"
            use:autoResizeTextarea
          ></textarea>
          {#if tzModalError}<div class="field-error" style="margin-top:6px">{tzModalError}</div>{/if}
          <div class="inline" style="gap:8px;margin-top:12px">
            <button class="btn" on:click={runTzImport} disabled={tzModalLoading || !tzModalText.trim()}>
              {tzModalLoading ? '⏳ Поиск…' : '🔍 Найти в RetailCRM'}
            </button>
            <button class="btn ghost" on:click={() => (tzModalBlockId = null)}>Отмена</button>
          </div>
        {:else}
          <div class="modal-step">Проверьте и отредактируйте данные</div>
          <div class="tz-table-wrap">
            <table class="tz-table">
              <thead>
                <tr>
                  <th style="width:36px"></th>
                  <th style="width:75px">ID</th>
                  <th style="width:160px">Имя</th>
                  <th>Описание</th>
                  <th style="width:80px">Ст. цена</th>
                  <th style="width:80px">Цена</th>
                  <th style="width:46px">%</th>
                  <th>Ссылка</th>
                </tr>
              </thead>
              <tbody>
                {#each tzModalResults as r, i}
                  <tr class:tz-row-warn={!r.match}>
                    <td class="tz-td-img">
                      {#if r.match?.image}<img src={r.match.image} alt="" />{:else}<span title="Не найдено">⚠️</span>{/if}
                    </td>
                    <td><input class="tz-cell" bind:value={tzModalResults[i].editId} /></td>
                    <td><input class="tz-cell" bind:value={tzModalResults[i].editName} /></td>
                    <td><input class="tz-cell" bind:value={tzModalResults[i].editCategory} /></td>
                    <td><input class="tz-cell tz-cell-num" bind:value={tzModalResults[i].editOldprice} /></td>
                    <td><input class="tz-cell tz-cell-num" bind:value={tzModalResults[i].editPrice} /></td>
                    <td><input class="tz-cell tz-cell-num" bind:value={tzModalResults[i].editDiscount} /></td>
                    <td><input class="tz-cell" bind:value={tzModalResults[i].editUrl} /></td>
                  </tr>
                {/each}
              </tbody>
            </table>
          </div>
          <div class="inline" style="gap:8px;margin-top:12px">
            <button class="btn" on:click={applyTzImport}>✅ Вставить код</button>
            <button class="btn ghost" on:click={() => { tzModalResults = []; tzModalText = '' }}>← Назад</button>
            <button class="btn ghost" on:click={() => (tzModalBlockId = null)}>Отмена</button>
          </div>
        {/if}
      </div>
    </div>
  {/if}

  {#if showImportPlanModal}
    <div class="modal-backdrop" on:click|self={() => (showImportPlanModal = false)}>
      <div class="modal-card" style="max-width:800px;max-height:85vh;overflow-y:auto">
        <div class="modal-title">📋 Импорт из контент-плана</div>
        {#if importPlanStep === 'paste'}
          <div class="modal-step">Вставьте текст контент-плана (весь файл)</div>
          <textarea
            class="field-control"
            rows="12"
            placeholder={"Письмо 1.\nТема: Поехали! 🚀\nПрехедер: Отмечаем День космонавтики\n\nБаннер\n\nТекст:\nДень космонавтики отмечается 12 апреля...\n\nТовары\n134496,294103\n\nКнопка Смотреть все монеты"}
            bind:value={importPlanText}
            style="font-size:12px;font-family:monospace"
          ></textarea>
          {#if importPlanError}<div class="field-error" style="margin-top:6px">{importPlanError}</div>{/if}
          {#if importPlanProgress}<div class="muted" style="margin-top:6px">{importPlanProgress}</div>{/if}
          <div class="inline" style="gap:8px;margin-top:12px">
            <button class="btn" on:click={parseImportPlan} disabled={!importPlanText.trim()}>
              Распознать письма
            </button>
            <button class="btn ghost" on:click={() => (showImportPlanModal = false)}>Отмена</button>
          </div>
        {:else if importPlanStep === 'preview'}
          <div class="modal-step">Найдено {importPlanParsed.length} писем. Выберите какие создать:</div>
          <div style="max-height:50vh;overflow-y:auto">
            {#each importPlanParsed as letter, i}
              <div class="import-letter-row" style="padding:8px;border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:flex-start">
                <input type="checkbox" bind:checked={importPlanParsed[i].selected} style="margin-top:4px" />
                <div style="flex:1;min-width:0">
                  <div style="font-weight:600;font-size:13px">{letter.subject || '(без темы)'}</div>
                  <div class="muted" style="font-size:11px">{letter.preheader || ''}</div>
                  <div style="font-size:11px;margin-top:2px">
                    <span class="muted">Дата:</span>
                    {#if letter.matched}
                      <span style="color:var(--accent)">{letter.date}</span> (из плана)
                    {:else}
                      <input type="date" bind:value={importPlanParsed[i].date} style="font-size:11px" />
                      <span class="muted">(не найдено в плане)</span>
                    {/if}
                  </div>
                  <div class="muted" style="font-size:10px;margin-top:2px">
                    {letter.blocks.length} блоков:
                    {letter.blocks.map(b => b.type === 'banner' ? '🖼' : b.type === 'text' ? '📝' : b.type === 'products' ? `🏷${b.ids?.length || 0}` : b.type === 'button' ? '🔘' : b.type === 'heading' ? '📌' : '?').join(' ')}
                  </div>
                </div>
              </div>
            {/each}
          </div>
          {#if importPlanError}<div class="field-error" style="margin-top:6px;white-space:pre-wrap">{importPlanError}</div>{/if}
          <div class="inline" style="gap:8px;margin-top:12px">
            <button class="btn" on:click={executeImportPlan} disabled={!importPlanParsed.some(l => l.selected)}>
              Создать {importPlanParsed.filter(l => l.selected).length} писем
            </button>
            <button class="btn ghost" on:click={() => { importPlanStep = 'paste' }}>← Назад</button>
            <button class="btn ghost" on:click={() => (showImportPlanModal = false)}>Отмена</button>
          </div>
        {:else if importPlanStep === 'creating'}
          <div class="muted" style="padding:20px;text-align:center">
            <div style="font-size:24px;margin-bottom:8px">⏳</div>
            {importPlanProgress}
          </div>
        {/if}
      </div>
    </div>
  {/if}

  {#if showNoteModal}
    <div class="modal-backdrop" on:click|self={() => (showNoteModal = false)}>
      <div class="modal-card">
        <div class="modal-title">Новая заметка</div>
        <div class="modal-step">Название заметки</div>
        <input
          class="field-control"
          type="text"
          placeholder="Название заметки"
          bind:value={noteTitleDraft}
          on:keydown={(event) => event.key === 'Enter' && createNote()}
        />
        {#if noteError}
          <div class="modal-error">{noteError}</div>
        {/if}
        <div class="modal-actions">
          <button class="btn ghost" on:click={() => (showNoteModal = false)}>Отмена</button>
          <button class="btn" on:click={createNote}>Создать</button>
        </div>
      </div>
    </div>
  {/if}
{#if quickEditVisible && quickEditBlock && quickEditField}
  <div
    class="quick-edit-popup"
    style={`top:${quickEditPos.top}px;left:${quickEditPos.left}px;`}
    on:click|stopPropagation
  >
    <div class="quick-edit-header">
      <span class="quick-edit-title">{quickEditBlock.name} — {quickEditField.label}</span>
      <button class="quick-edit-close" on:click={closeQuickEdit} title="Закрыть">✕</button>
    </div>
    <CodeEditor
      value={quickEditValue}
      rows={3}
      extraKeys={quickEditKeys}
      onchange={(v) => { quickEditValue = v; if (quickEditBlock && quickEditField) updateField(quickEditBlock, quickEditField, v) }}
    />
  </div>
{/if}
</div>

{#if autoAssembleOpen}
  <!-- svelte-ignore a11y-click-events-have-key-events -->
  <div class="modal-backdrop" on:click|self={() => (autoAssembleOpen = false)}>
    <div class="modal-card" style="width:680px;max-width:96vw">
      <div class="modal-title">🎲 Автоподбор</div>
      <div class="auto-assemble-form">
        <div class="auto-assemble-row">
          <label>Тип письма:</label>
          <div class="auto-assemble-radio-group">
            <label class="auto-assemble-radio"><input type="radio" bind:group={autoAssembleType} value="new" /> Новинки</label>
            <label class="auto-assemble-radio"><input type="radio" bind:group={autoAssembleType} value="sale" /> Акции (со скидкой)</label>
          </div>
        </div>
        <div class="auto-assemble-row">
          <label>Тип товара:</label>
          <div class="auto-assemble-radio-group">
            <label class="auto-assemble-radio"><input type="radio" bind:group={autoAssembleProductType} value="coin" /> Монеты</label>
            <label class="auto-assemble-radio"><input type="radio" bind:group={autoAssembleProductType} value="banknote" /> Банкноты</label>
            <label class="auto-assemble-radio"><input type="radio" bind:group={autoAssembleProductType} value="any" /> Всё</label>
          </div>
        </div>
        <div class="auto-assemble-row">
          <label>Цена:</label>
          <div class="auto-assemble-radio-group">
            <label class="auto-assemble-radio"><input type="radio" bind:group={autoAssemblePricePreset} value="any" /> Любая</label>
            <label class="auto-assemble-radio"><input type="radio" bind:group={autoAssemblePricePreset} value="custom" /> Своя</label>
            {#if autoAssemblePricePreset === 'custom'}
              <div class="auto-assemble-price-custom">
                <input class="field-control" type="number" placeholder="от" bind:value={autoAssemblePriceMin} />
                <span>—</span>
                <input class="field-control" type="number" placeholder="до" bind:value={autoAssemblePriceMax} />
                <span class="muted">₽</span>
              </div>
            {/if}
          </div>
        </div>
        <div class="auto-assemble-row">
          <label>Исключить металлы:</label>
          <div class="auto-assemble-radio-group">
            <label class="auto-assemble-radio"><input type="checkbox" bind:checked={autoAssembleExcludeGold} /> Золото</label>
            <label class="auto-assemble-radio"><input type="checkbox" bind:checked={autoAssembleExcludeSilver} /> Серебро</label>
            <label class="auto-assemble-radio"><input type="checkbox" bind:checked={autoAssembleExcludePlatinum} /> Платина</label>
          </div>
        </div>
        <div class="auto-assemble-row">
          <label>Схема:</label>
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
            <input class="field-control" type="text" bind:value={autoAssembleLayout} placeholder="3 1 3 3" style="width:180px;font-family:monospace" />
            <span class="muted">= {autoAssembleLayout.trim().split(/[\s,]+/).map(Number).filter(n => n >= 1 && n <= 3).reduce((s, n) => s + n, 0)} монет</span>
            <div class="auto-assemble-radio-group" style="margin-left:8px">
              {#each ['3 3 3', '3 1 3 3', '3 2 3 2 3', '3 3 3 3 3'] as preset}
                <button class="btn btn-sm" class:btn-accent={autoAssembleLayout === preset} on:click={() => autoAssembleLayout = preset}>{preset}</button>
              {/each}
            </div>
          </div>
        </div>
        <div class="modal-actions">
          {#if !autoAssembleBlocks.length}
            <button class="btn btn-accent" on:click={runAutoAssemble} disabled={autoAssembleLoading}>
              {autoAssembleLoading ? 'Загрузка...' : 'Подобрать'}
            </button>
          {:else}
            <button class="btn btn-primary" on:click={applyAutoAssemble}>
              ✓ Добавить в письмо ({autoAssembleBlocks.length} блоков)
            </button>
            <button class="btn btn-accent" on:click={runAutoAssemble} disabled={autoAssembleLoading}>
              {autoAssembleLoading ? '...' : '🔄 Перегенерировать'}
            </button>
          {/if}
          <button class="btn" on:click={() => autoAssembleOpen = false}>Отмена</button>
        </div>
        {#if autoAssembleError}
          <div class="modal-error">{autoAssembleError}</div>
        {/if}
      </div>
      {#if autoAssembleBlocks.length}
        <div class="auto-assemble-preview">
          <div class="auto-assemble-preview-title">Результат ({autoAssembleBlocks.reduce((s, b) => s + b.ids.length, 0)} монет в {autoAssembleBlocks.length} блоках):</div>
          {#each autoAssembleBlocks as b, i}
            <div class="auto-assemble-block">
              <div class="auto-assemble-block-header">
                <span class="auto-assemble-block-num">Блок {i + 1}</span>
                <span class="auto-assemble-block-mixin">{b.mixin}</span>
                <span class="auto-assemble-block-country">{b.country}</span>
                <button class="btn btn-sm" on:click={() => regenerateSlot(i)} disabled={b._loading} title="Перегенерировать этот блок">
                  {b._loading ? '...' : '🔄'}
                </button>
              </div>
              <div class="auto-assemble-products">
                {#each b.products as p, pi}
                  <div class="auto-assemble-product" class:auto-assemble-product-loading={p._loading} on:click={() => replaceCoin(i, pi)} title="Нажмите чтобы заменить">
                    {#if p.usedDaysAgo !== null && p.usedDaysAgo !== undefined && p.usedDaysAgo <= 25}
                      <div class="auto-assemble-used-badge" class:auto-assemble-used-red={p.usedDaysAgo <= 15} class:auto-assemble-used-yellow={p.usedDaysAgo > 15} title="{p.usedDaysAgo === 0 ? 'Использовалась сегодня' : 'Использовалась ' + p.usedDaysAgo + ' дн. назад'}">{p.usedDaysAgo === 0 ? 'сег' : p.usedDaysAgo + 'д'}</div>
                    {/if}
                    {#if p.image}<img src={p.image} alt="" class="auto-assemble-img" />{/if}
                    <div class="auto-assemble-product-info">
                      <div class="auto-assemble-product-name" title={p.name}>{p.name}</div>
                      <div class="auto-assemble-product-price">
                        {p.price ? p.price + ' ₽' : ''}
                        {#if p.salePercent}<span class="auto-assemble-sale">−{p.salePercent}%</span>{/if}
                      </div>
                      <div class="auto-assemble-product-id muted">#{p.id}</div>
                    </div>
                  </div>
                {/each}
              </div>
            </div>
          {/each}
        </div>
      {/if}
    </div>
  </div>
{/if}

{#if toasts.length > 0}
  <div class="toast-container">
    {#each toasts as toast (toast.id)}
      <div class="toast toast-{toast.type}">
        <div class="toast-text">{toast.message}</div>
        <button class="toast-close" on:click={() => toasts = toasts.filter(t => t.id !== toast.id)}>×</button>
      </div>
    {/each}
  </div>
{/if}

{/if}
