const express = require('express')
const path = require('path')
const fs = require('fs')
const Database = require('better-sqlite3')
const cron = require('node-cron')
const { parse: parseHTML } = require('node-html-parser')

const { generateCoinEmail, setGradeScore } = require('./coin-writer')

const PORT = 5180
const DB_PATH = path.resolve(__dirname, 'data', 'coins.db')
const app = express()

app.use(express.json())
app.use(express.static(path.resolve(__dirname, 'public')))

// ─── Feed sources ───
const FEEDS = [
  { id: 'AT', name: 'numizm.at', url: 'https://numizm.at/bitrix/catalog_export/mindbox.php', encoding: 'windows-1251' },
  { id: 'KB', name: 'coinsbolhov.ru', url: 'https://coinsbolhov.ru/bitrix/catalog_export/mindbox.php' },
  { id: 'RU', name: 'numizmat.ru', url: 'https://numizmat.ru/bitrix/catalog_export/mindbox.php', encoding: 'windows-1251' },
]

// ─── Database ───
function initDB() {
  const dir = path.dirname(DB_PATH)
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
  const db = new Database(DB_PATH)
  db.pragma('journal_mode = WAL')
  db.exec(`
    CREATE TABLE IF NOT EXISTS coins (
      id TEXT PRIMARY KEY,
      feed TEXT NOT NULL,
      name TEXT NOT NULL,
      price REAL,
      old_price REAL,
      url TEXT,
      image TEXT,
      category TEXT,
      first_seen TEXT DEFAULT (datetime('now')),
      last_seen TEXT DEFAULT (datetime('now')),
      available INTEGER DEFAULT 1
    );
    CREATE TABLE IF NOT EXISTS coin_details (
      coin_id TEXT PRIMARY KEY,
      grade TEXT,
      material TEXT,
      weight TEXT,
      diameter TEXT,
      year_from INTEGER,
      year_to INTEGER,
      country TEXT,
      in_stock INTEGER DEFAULT 1,
      parsed_at TEXT,
      FOREIGN KEY (coin_id) REFERENCES coins(id)
    );
    CREATE TABLE IF NOT EXISTS scan_log (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      started_at TEXT DEFAULT (datetime('now')),
      finished_at TEXT,
      new_coins INTEGER DEFAULT 0,
      removed_coins INTEGER DEFAULT 0,
      details_parsed INTEGER DEFAULT 0,
      status TEXT DEFAULT 'running'
    );
    CREATE TABLE IF NOT EXISTS settings (
      key TEXT PRIMARY KEY,
      value TEXT
    );
    CREATE TABLE IF NOT EXISTS price_history (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      coin_id TEXT NOT NULL,
      price REAL,
      recorded_at TEXT DEFAULT (datetime('now')),
      FOREIGN KEY (coin_id) REFERENCES coins(id)
    );
    CREATE INDEX IF NOT EXISTS idx_ph_coin ON price_history(coin_id);
    CREATE INDEX IF NOT EXISTS idx_ph_date ON price_history(recorded_at);
  `)
  // Default settings
  const defaults = {
    max_price: '3000',
    min_grade: 'VF',
    preferred_material: 'серебро,золото',
    auto_scan_hour: '8',
    scan_enabled: '1',
    hide_dupes: '1',
  }
  const upsert = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)')
  for (const [k, v] of Object.entries(defaults)) upsert.run(k, v)
  return db
}

const db = initDB()

// ─── Grade scoring ───
const GRADE_ORDER = ['G', 'AG', 'VG', 'F', 'VF', 'XF', 'EF', 'AU', 'UNC', 'BU', 'Proof']
function gradeScore(grade) {
  if (!grade) return -1
  const g = grade.toUpperCase().replace(/[\s\-\+\.]/g, '')
  for (let i = GRADE_ORDER.length - 1; i >= 0; i--) {
    if (g.includes(GRADE_ORDER[i].toUpperCase())) return i
  }
  return -1
}
function gradeAtLeast(grade, minGrade) {
  return gradeScore(grade) >= gradeScore(minGrade)
}

// Inject gradeScore into coin-writer
setGradeScore(gradeScore)

// ─── Feed file cache (disk) ───
const FEED_CACHE_DIR = path.resolve(__dirname, 'data', 'feed-cache')
if (!fs.existsSync(FEED_CACHE_DIR)) fs.mkdirSync(FEED_CACHE_DIR, { recursive: true })

function saveFeedCache(feedId, items) {
  const file = path.resolve(FEED_CACHE_DIR, `${feedId}.json`)
  fs.writeFileSync(file, JSON.stringify(items))
}
function loadFeedCache(feedId) {
  const file = path.resolve(FEED_CACHE_DIR, `${feedId}.json`)
  if (!fs.existsSync(file)) return null
  try { return JSON.parse(fs.readFileSync(file, 'utf-8')) } catch { return null }
}

// ─── Feed parsing (Mindbox YML) ───
async function fetchFeed(feedCfg) {
  console.log(`[feed] Fetching ${feedCfg.name}...`)
  const res = await fetch(feedCfg.url, {
    signal: AbortSignal.timeout(120000),
    headers: { 'User-Agent': 'Mozilla/5.0 (compatible; CoinScout/1.0)' },
  })
  if (!res.ok) throw new Error(`Feed ${feedCfg.name}: HTTP ${res.status}`)

  // Stream parse to avoid memory issues with huge feeds (100MB+)
  const items = []
  const reader = res.body.getReader()
  const decoder = new TextDecoder(feedCfg.encoding || 'utf-8')
  let buffer = ''
  let totalBytes = 0

  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    totalBytes += value.length
    buffer += decoder.decode(value, { stream: true })

    // Extract complete <offer>...</offer> blocks from buffer
    let startIdx
    while ((startIdx = buffer.indexOf('<offer ')) !== -1) {
      const endIdx = buffer.indexOf('</offer>', startIdx)
      if (endIdx === -1) break // incomplete, wait for more data
      const offerStr = buffer.substring(startIdx + 7, endIdx) // after "<offer "
      buffer = buffer.substring(endIdx + 8) // after "</offer>"

      const tagEnd = offerStr.indexOf('>')
      if (tagEnd === -1) continue
      const attrs = offerStr.substring(0, tagEnd)
      const idMatch = attrs.match(/id="([^"]*)"/)
      const availMatch = attrs.match(/available="([^"]*)"/)
      if (!idMatch) continue
      const id = idMatch[1]
      const available = availMatch ? availMatch[1] : 'true'
      const body = offerStr.substring(tagEnd + 1)

      const get = (tag) => {
        const s = body.indexOf(`<${tag}`)
        if (s === -1) return ''
        const cs = body.indexOf('>', s) + 1
        const e = body.indexOf(`</${tag}>`, cs)
        if (e === -1) return ''
        return body.substring(cs, e).trim()
      }
      const price = parseFloat(get('price')) || 0
      const oldPrice = parseFloat(get('oldprice')) || 0
      const name = get('name').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
      const url = get('url')
      const image = get('picture')
      const category = get('categoryId')
      // Extra fields from feed (may exist)
      const grade = get('condition') || ''
      const material = get('material') || ''
      const weight = get('weight') || get('Weight') || ''
      const diameter = get('dia') || ''
      const year = get('year') || ''
      items.push({
        id: `${feedCfg.id}_${id}`,
        feed: feedCfg.id,
        name,
        price,
        old_price: oldPrice,
        url,
        image,
        category,
        available: available === 'true' ? 1 : 0,
        grade, material, weight, diameter, year,
      })
    }
    // Keep only last incomplete chunk (trim everything before last possible <offer)
    if (buffer.length > 100000) {
      const lastOffer = buffer.lastIndexOf('<offer')
      if (lastOffer > 0) buffer = buffer.substring(lastOffer)
    }
  }

  console.log(`[feed] ${feedCfg.name}: ${(totalBytes / 1024).toFixed(0)} KB, ${items.length} items`)
  return items
}

// ─── Product page parsing ───
async function parseProductPage(url) {
  try {
    const res = await fetch(url, {
      signal: AbortSignal.timeout(15000),
      headers: { 'User-Agent': 'Mozilla/5.0 (compatible; CoinScout/1.0)' },
    })
    if (!res.ok) return null
    const html = await res.text()
    const root = parseHTML(html)

    const result = { grade: '', material: '', weight: '', diameter: '', year_from: null, year_to: null, country: '', in_stock: 0 }

    // In stock check
    const bodyText = root.text || ''
    if (/в наличии|в\s*корзину|купить|add.to.cart/i.test(bodyText) && !/нет в наличии/i.test(bodyText)) {
      result.in_stock = 1
    }
    if (/нет в наличии|sold.out|отсутствует/i.test(bodyText)) {
      result.in_stock = 0
    }

    // Parse characteristics table
    const rows = root.querySelectorAll('tr, .char-item, .properties__item, [class*="prop"]')
    for (const row of rows) {
      const text = row.text.toLowerCase()
      const val = row.querySelectorAll('td, span, div')
      const rawVal = val.length >= 2 ? val[val.length - 1].text.trim() : row.text.trim()

      if (/сохранность|состояние|grade|грейд/.test(text)) {
        result.grade = rawVal.replace(/\s+/g, ' ').trim()
      }
      if (/материал|металл|metal/.test(text)) {
        result.material = rawVal
      }
      if (/вес|масса|weight/.test(text)) {
        result.weight = rawVal
      }
      if (/диаметр|diameter/.test(text)) {
        result.diameter = rawVal
      }
      if (/страна|country/.test(text)) {
        result.country = rawVal
      }
    }

    // Try to extract grade from page if not found in table
    if (!result.grade) {
      const gradeMatch = bodyText.match(/(?:сохранность|состояние|grade)[:\s]*([A-Za-z\+\-\/]+)/i)
      if (gradeMatch) result.grade = gradeMatch[1].trim()
    }

    // Try extract years from name/URL
    const yearMatch = (url + ' ' + bodyText).match(/(\d{3,4})\s*[-–]\s*(\d{3,4})\s*(?:год|г\.|г\b|до н)/i)
    if (yearMatch) {
      result.year_from = parseInt(yearMatch[1])
      result.year_to = parseInt(yearMatch[2])
    } else {
      const singleYear = url.match(/(\d{4})_goda/)
      if (singleYear) {
        result.year_from = parseInt(singleYear[1])
        result.year_to = result.year_from
      }
    }

    return result
  } catch (e) {
    console.error(`[parse] Error parsing ${url}: ${e.message}`)
    return null
  }
}

// ─── Guess material from coin name ───
function guessMaterial(name) {
  const n = name.toLowerCase()
  if (/золот|gold|\bgold\b/.test(n)) return 'Золото'
  if (/серебр|silver|\bsilver\b|биллон|billon/.test(n)) return 'Серебро'
  if (/медно-?никел|мельхиор|cupronickel/i.test(n)) return 'Медно-никель'
  if (/медь|copper|\bcopper\b|бронз|bronze/.test(n)) return 'Медь'
  return ''
}

// ─── Build email newsletter copy (2-3 paragraphs) ───
function buildEmailCopy(coin, details, score) {
  const name = coin.name || ''
  const mat = (details.material || '').toLowerCase()
  const year = details.year_from || 0
  const age = year ? 2026 - year : 0
  const price = coin.price || 0
  const grade = details.grade || ''
  const gs = gradeScore(grade)
  const weightG = parseFloat(details.weight) || 0

  const isSilver = /серебро|silver/.test(mat)
  const isGold = /золото|gold/.test(mat)
  const isPrecious = isSilver || isGold
  const metalName = isGold ? 'золото' : isSilver ? 'серебро' : (details.material || 'металл')

  // Paragraph 1 — Hook: what is this coin, why it's special
  const hooks = []
  if (year && year < 0) {
    hooks.push(`${name} — подлинный свидетель эпохи, которой более ${Math.abs(year) + 2026} лет. Эта монета существовала задолго до падения Рима и рождения большинства европейских государств. Держать в руках предмет такой древности — ощущение, которое невозможно описать словами.`)
  } else if (age > 400) {
    hooks.push(`${name} — ${age} лет истории в вашей ладони. Эта монета была отчеканена в эпоху, когда мир выглядел совершенно иначе. Немногие предметы способны так осязаемо связать нас с далёким прошлым.`)
  } else if (age > 200) {
    hooks.push(`${name} — монета, пережившая больше двух столетий. Она видела смену эпох, войн и империй, и дошла до наших дней${gs >= gradeScore('VF') ? ' в прекрасном состоянии' : ''}. Настоящий осколок ушедшей эпохи.`)
  } else if (age > 100) {
    hooks.push(`${name} — больше века истории. Монета, отчеканенная в начале прошлого столетия, когда мир стоял на пороге перемен.${isPrecious ? ` ${metalName === 'золото' ? 'Золотая' : 'Серебряная'} — а значит, ценна и как металл, и как предмет коллекционирования.` : ''}`)
  } else {
    hooks.push(`${name}${isPrecious ? ` — ${metalName === 'золото' ? 'золотая' : 'серебряная'} монета` : ''}${gs >= gradeScore('UNC') ? ' в идеальной сохранности' : gs >= gradeScore('AU') ? ' в отличном состоянии' : ''}.${year ? ` ${year} год.` : ''}`)
  }

  // Add period flavor
  if (/николай\s*ii|николая\s*ii/i.test(name)) {
    hooks.push('Последний российский император, чьё правление стало одним из самых трагических и притягательных периодов русской истории. Монеты Николая II — неизменный фаворит коллекционеров.')
  } else if (/пётр|петр\s*i|петра\s*i/i.test(name)) {
    hooks.push('Монета эпохи Петра Великого — царя-реформатора, изменившего облик России навсегда. Коллекционный спрос на монеты этого периода стабильно растёт.')
  } else if (/екатерин/i.test(name)) {
    hooks.push('Эпоха Екатерины Великой — золотой век Российской империи. Монеты этого периода сочетают историческую значимость с коллекционной привлекательностью.')
  } else if (/александр\s*ii/i.test(name)) {
    hooks.push('Царь-Освободитель, отменивший крепостное право. Интерес коллекционеров к монетам Александра II стабильно растёт.')
  } else if (/финлянд/i.test(name)) {
    hooks.push('Монеты Русской Финляндии — особая глава в истории нумизматики. Отдельная серия с ограниченным ареалом обращения всегда привлекает серьёзных коллекционеров.')
  } else if (/римск|roman|денарий|антониниан/i.test(name)) {
    hooks.push('Монета Римской империи — цивилизации, заложившей фундамент западного мира. Глобальный рынок античных монет растёт на 8-12% ежегодно.')
  } else if (/греч|greek|драхм|обол|тетра/i.test(name)) {
    hooks.push('Монета Древней Греции — колыбели демократии и философии. Рынок греческих античных монет показывает рост до 15% в год.')
  } else if (/осман|ottoman/i.test(name)) {
    hooks.push('Монета Османской империи — одного из величайших государств в истории. Исламская нумизматика — растущий и пока недооценённый сегмент рынка.')
  } else if (/сефевид|safavid/i.test(name)) {
    hooks.push('Монета Сефевидской Персии — империи шахов, объединившей Иран. Редкий исламский регион, интерес к которому среди коллекционеров неуклонно растёт.')
  } else if (/боспор|пантикапей/i.test(name)) {
    hooks.push('Монета Боспорского царства — античного государства на берегах Чёрного моря. Крымская античность — уникальная ниша с растущим спросом.')
  } else if (/визант|byzant/i.test(name)) {
    hooks.push('Монета Византийской империи — тысячелетнего наследника Рима. Византийская нумизматика привлекает всё больше коллекционеров.')
  } else if (/япон|japan/i.test(name)) {
    hooks.push('Монета Японии — страны с богатейшей нумизматической традицией. Азиатский рынок коллекционных монет растёт на 7% в год.')
  } else if (/1921|1922|1923|1924|1925|1926|1927|1928|1929|1930|1931/.test(name) && /серебро|silver/.test(mat)) {
    hooks.push('Серебряная монета раннего СССР — периода, когда серебро ещё использовалось в обращении. Тиражи были ограничены, а большая часть была изъята и переплавлена.')
  }

  // Paragraph 2 — Value proposition: grade, material, melt value
  const value = []
  if (gs >= gradeScore('UNC')) {
    value.push(`Сохранность ${grade} — монета никогда не была в обращении. Все детали рельефа идеальны, присутствует оригинальный блеск. Это высшая категория для коллекционера.`)
  } else if (gs >= gradeScore('AU')) {
    value.push(`Сохранность ${grade} — почти идеальное состояние с минимальными следами обращения. Такой грейд${age > 100 ? ` для монеты возрастом ${age} лет` : ''} встречается нечасто.`)
  } else if (gs >= gradeScore('XF')) {
    value.push(`Сохранность ${grade} — высокое качество${age > 100 ? ` для монеты ${age}-летней давности` : ''}. Все детали чёткие и хорошо различимы.`)
  } else if (gs >= gradeScore('VF') && age > 200) {
    value.push(`Сохранность ${grade} — достойное состояние для монеты возрастом ${age} лет. Большинство монет этой эпохи дошли до нас в значительно худшем виде.`)
  }

  if (isSilver && weightG > 0 && price > 0) {
    const melt = Math.round(weightG * SILVER_PRICE_PER_GRAM)
    const ratio = Math.round(melt / price * 100)
    if (ratio > 100) {
      value.push(`Содержит ${weightG}г серебра стоимостью ~${melt}₽ — при цене монеты всего ${price}₽. Вы покупаете серебро дешевле его рыночной стоимости, а нумизматическую ценность получаете бонусом.`)
    } else if (ratio > 60) {
      value.push(`${weightG}г серебра обеспечивают ${ratio}% цены. Драгоценный металл создаёт надёжный «пол» стоимости — даже при колебаниях нумизматического рынка.`)
    }
  } else if (isGold) {
    value.push(`Золотая монета — актив, ценность которого проверена тысячелетиями. Золото защищает от инфляции и обеспечивает стабильный рост.`)
  }

  if (coin.old_price && coin.old_price > price) {
    const disc = Math.round((coin.old_price - price) / coin.old_price * 100)
    if (disc >= 15) {
      value.push(`Сейчас доступна со скидкой ${disc}% от прежней цены ${coin.old_price}₽.`)
    }
  }

  // Paragraph 3 — Call to action / scarcity
  const cta = []
  if (age > 200) {
    cta.push(`С каждым годом таких монет становится всё меньше — утраты, повреждения и оседание в частных коллекциях делают каждый доступный экземпляр ценнее предыдущего.`)
  }
  if (isPrecious && gs >= gradeScore('VF')) {
    cta.push(`${metalName === 'золото' ? 'Золото' : 'Серебро'} в сочетании с хорошей сохранностью — формула, которая работает на протяжении всей истории нумизматики.`)
  }
  if (price <= 1000 && score >= 40) {
    cta.push(`При цене ${price}₽ это одна из самых доступных монет с высоким инвестиционным потенциалом в нашем каталоге.`)
  } else if (price <= 3000 && score >= 50) {
    cta.push(`${price}₽ за монету с таким набором характеристик — привлекательная цена.`)
  }
  cta.push(`Цена: ${price}₽`)

  return [hooks.join(' '), value.join(' '), cta.join(' ')].filter(p => p.trim()).join('\n\n')
}

// ─── Build human-readable investment summary (short + detailed) ───
function buildSummary(coin, details, score, reasons) {
  const short = []
  const full = []
  const mat = (details.material || '').toLowerCase()
  const year = details.year_from || 0
  const age = year ? 2026 - year : 0
  const price = coin.price || 0
  const gs = gradeScore(details.grade)
  const weightG = parseFloat(details.weight) || 0
  const grade = details.grade || '?'

  // ── Investment tier ──
  if (score >= 60) { short.push('Топ-находка.'); full.push('🏆 Инвестиционный рейтинг: ОТЛИЧНЫЙ. Эта монета набрала один из высших баллов в нашей системе оценки, основанной на 40+ профессиональных источников по нумизматике.') }
  else if (score >= 45) { short.push('Сильная монета.'); full.push('⭐ Инвестиционный рейтинг: ВЫСОКИЙ. Монета показывает сильные результаты по нескольким ключевым критериям оценки.') }
  else if (score >= 30) { short.push('Хороший вариант.'); full.push('✅ Инвестиционный рейтинг: ХОРОШИЙ. Монета соответствует основным критериям инвестиционной привлекательности.') }
  else { full.push('📊 Инвестиционный рейтинг: СРЕДНИЙ. Монета имеет отдельные привлекательные характеристики.') }

  // ── Grade analysis ──
  full.push('')
  full.push('📋 СОХРАННОСТЬ')
  if (gs >= gradeScore('Proof')) {
    short.push('Proof — высшее качество чеканки.')
    full.push(`Грейд: ${grade} (Proof) — монета специального чекана с зеркальной поверхностью и матовым рельефом. Это высшая категория качества, которая всегда ценится коллекционерами. Proof-монеты чеканятся полированными штемпелями на специально подготовленных заготовках.`)
  } else if (gs >= gradeScore('UNC')) {
    short.push('UNC — не была в обращении.')
    full.push(`Грейд: ${grade} (Uncirculated) — монета никогда не находилась в денежном обращении. Все мельчайшие детали рельефа сохранены, присутствует оригинальный штемпельный блеск. Переход AU→UNC — самый значительный скачок стоимости в нумизматике (данные PCGS).`)
  } else if (gs >= gradeScore('AU')) {
    short.push(`${grade} — отличное состояние.`)
    full.push(`Грейд: ${grade} (About Uncirculated) — минимальные следы обращения видны только на самых выступающих точках рельефа. Сохраняется значительная часть штемпельного блеска. Отличное состояние для коллекционирования и инвестиций.`)
  } else if (gs >= gradeScore('XF')) {
    short.push(`${grade} — высокая сохранность.`)
    full.push(`Грейд: ${grade} (Extremely Fine) — лёгкий износ на выступающих частях рельефа, но все детали чёткие и хорошо читаемые. Хорошее состояние для монет этой ценовой категории.`)
  } else if (gs >= gradeScore('VF')) {
    full.push(`Грейд: ${grade} (Very Fine) — умеренный износ, все основные элементы дизайна различимы. Для старинных монет VF — вполне приемлемая сохранность.`)
  }
  if (year && year < 1800 && gs >= gradeScore('VF')) {
    full.push(`⚡ Монета ${year} года в состоянии ${grade} — это исключительная сохранность! Большинство монет этого периода дошли до нас в состоянии F и ниже. Хорошо сохранившиеся экземпляры составляют менее 5-15% от всех выпущенных (оценка по данным PCGS).`)
  } else if (year && year < 1900 && gs >= gradeScore('XF')) {
    full.push(`Для монеты XIX века грейд ${grade} — это высокая сохранность, такие экземпляры встречаются нечасто.`)
  }

  // ── Material & melt value ──
  full.push('')
  full.push('💰 МАТЕРИАЛ И СТОИМОСТЬ МЕТАЛЛА')
  if (/золото|gold/.test(mat)) {
    short.push('Золото — надёжный актив.')
    full.push(`Материал: золото. Золотые монеты — наиболее надёжный нумизматический актив. Цена золотых монет практически никогда не падает ниже стоимости содержащегося в них металла (melt value floor). Средняя доходность золотых инвестиционных монет: 10.9% годовых за 26 лет (American Gold Eagle, 2000-2026).`)
  } else if (/серебро|silver/.test(mat)) {
    if (weightG > 0 && price > 0) {
      const melt = Math.round(weightG * SILVER_PRICE_PER_GRAM)
      const ratio = Math.round(melt / price * 100)
      if (ratio > 100) {
        short.push(`Дешевле стоимости серебра (${melt}₽)!`)
        full.push(`Материал: серебро (${weightG}г). Стоимость серебра в монете: ~${melt}₽ (по курсу ЦБ РФ ~${SILVER_PRICE_PER_GRAM}₽/г). При цене ${price}₽ монета стоит ДЕШЕВЛЕ содержащегося в ней металла! Это означает практически безрисковую покупку — даже без нумизматической ценности вы можете получить прибыль просто на стоимости серебра.`)
      } else if (ratio > 60) {
        short.push(`${ratio}% цены = серебро.`)
        full.push(`Материал: серебро (${weightG}г). Стоимость серебра: ~${melt}₽ (${ratio}% от цены монеты). Высокая обеспеченность драгоценным металлом создаёт «пол» стоимости — монета вряд ли подешевеет ниже ${melt}₽ даже при падении нумизматического спроса.`)
      } else {
        full.push(`Материал: серебро (${weightG}г). Стоимость металла: ~${melt}₽ (${ratio}% от цены). Основная ценность — нумизматическая, но серебро создаёт базовую поддержку цены.`)
      }
    } else {
      full.push(`Материал: серебро. Серебряные монеты — классический объект нумизматических инвестиций. Серебро создаёт «пол» стоимости и обеспечивает ликвидность.`)
    }
  } else if (/биллон|billon/.test(mat)) {
    full.push(`Материал: биллон (сплав с содержанием серебра). Исторически биллонные монеты чеканились в периоды экономических трудностей, что придаёт им дополнительную историческую ценность.`)
  } else if (mat) {
    full.push(`Материал: ${details.material || mat}. Монеты из недрагоценных металлов ценятся прежде всего за редкость, историческую значимость и состояние.`)
  }

  // ── Historical context ──
  if (year) {
    full.push('')
    full.push('📜 ИСТОРИЧЕСКАЯ ЗНАЧИМОСТЬ')
    if (year < 0) {
      full.push(`Дата: ${Math.abs(year)} г. до н.э. Монете более ${Math.abs(year) + 2026} лет — это подлинный античный артефакт. Рынок античных монет показывает стабильный рост 8-15% годовых (данные numisdon.com). Греческие городские монеты (Афины, Коринф, Сиракузы) выросли на ~15% в год с 2020 года.`)
      short.push('Античный артефакт.')
    } else if (age > 300) {
      full.push(`Дата: ${year} г. (${age} лет). Монетам этого периода свойственно устойчивое удорожание: предложение на рынке сокращается каждый год (утрата, повреждения, оседание в коллекциях), а спрос растёт. По данным Forbes.ru, царские монеты растут на 10-15% ежегодно.`)
      short.push(`${age} лет — предложение сокращается.`)
    } else if (age > 100) {
      full.push(`Дата: ${year} г. (${age} лет). Монеты старше 100 лет представляют интерес как исторические артефакты и объекты инвестиций. Предложение на рынке постепенно сокращается.`)
    }
  }

  // ── Price analysis ──
  full.push('')
  full.push('💲 АНАЛИЗ ЦЕНЫ')
  if (coin.old_price && coin.old_price > price) {
    const disc = Math.round((coin.old_price - price) / coin.old_price * 100)
    if (disc >= 15) {
      short.push(`Скидка ${disc}%.`)
      full.push(`Текущая цена: ${price}₽ (была ${coin.old_price}₽, скидка ${disc}%). Снижение цены при сохранении всех качественных характеристик — хорошая точка входа.`)
    } else {
      full.push(`Текущая цена: ${price}₽ (была ${coin.old_price}₽).`)
    }
  } else {
    full.push(`Текущая цена: ${price}₽.`)
  }
  if (score >= 50 && price <= 1000) {
    full.push('Низкая цена при высоком скоринге — потенциально недооценённая монета.')
  }
  if (gs >= gradeScore('AU') && price <= 500) {
    full.push(`${grade} за ${price}₽ — значительно ниже типичной рыночной стоимости для монет такого грейда.`)
  }

  // ── Unique context from reasons ──
  const uniqueReasons = reasons.filter(r =>
    !r.startsWith('Proof') && !r.startsWith('UNC') && !r.startsWith('AU') && !r.startsWith('XF') && !r.startsWith('VF') &&
    !r.startsWith('Золото') && !r.startsWith('Серебро') && !r.startsWith('Биллон') &&
    !/г\. —/.test(r) && !/стоимости/.test(r)
  )
  if (uniqueReasons.length) {
    full.push('')
    full.push('🔍 ОСОБЕННОСТИ')
    uniqueReasons.forEach(r => full.push(`• ${r}`))
  }

  // ── Recommendation ──
  full.push('')
  full.push('📈 РЕКОМЕНДАЦИЯ')
  if (score >= 60) {
    full.push(`«${coin.name}» — одна из лучших находок в текущем каталоге. Рекомендуется к покупке при наличии бюджета.`)
  } else if (score >= 45) {
    full.push(`«${coin.name}» заслуживает внимания. Хорошее соотношение цена/качество для долгосрочного портфеля.`)
  } else {
    full.push(`Монета может быть интересна как часть диверсифицированного портфеля.`)
  }
  if (/серебро|silver|золото|gold/.test(mat) && gs >= gradeScore('VF')) {
    full.push('Драгметалл + хорошая сохранность = надёжная комбинация на горизонте 5-10 лет. Средняя доходность: 4.8-14% годовых (PCGS, Penn State study).')
  }
  full.push(`Рекомендуемый срок владения: 7-10 лет. Учитывайте дилерскую наценку 10-30%.`)

  // ── Email copy (2-3 paragraphs for newsletters) ──
  const email = generateCoinEmail(coin, details, score)

  return { short: short.join(' '), full: full.join('\n'), email }
}

// ─── Check if item is actually a coin ───
function isCoin(name) {
  const n = name.toLowerCase()
  // Exclude non-coins
  if (/открытк|альбом|капсул|лист[ыа]\b|подставк|футляр|лупа|пинцет|рамк|планшет|холдер|книг/.test(n)) return false
  return true
}

// ─── Investment score + reasons ───
// Based on research from 12+ professional numismatic sources:
// numizmatik.ru, Forbes.ru, kp.ru, thecoinsexplorer.com, trustedcoins.com,
// NGC, PCGS guides, coinweek.com, numisdon.com, zolotoy-zapas.ru, etc.
// Silver price ~200 RUB/gram (ЦБ РФ, April 2026)
const SILVER_PRICE_PER_GRAM = 200

function investmentScore(coin, details) {
  let score = 0
  const reasons = []
  const name = (coin.name || '').toLowerCase()
  const mat = (details.material || '').toLowerCase()
  const gs = gradeScore(details.grade)
  const year = details.year_from || 0
  const age = year ? 2026 - year : 0
  const price = coin.price || 0

  // ══════════════════════════════════
  // 1. GRADE (max ~30 pts)
  // Grading impact: каждый шаг грейда может x2-x50 цену
  // AU→UNC — самый большой скачок стоимости
  // ══════════════════════════════════
  if (gs >= gradeScore('Proof')) { score += 28; reasons.push('Proof — зеркальная поверхность') }
  else if (gs >= gradeScore('UNC')) { score += 25; reasons.push('UNC — не была в обращении') }
  else if (gs >= gradeScore('AU')) { score += 20; reasons.push('AU — почти без следов обращения') }
  else if (gs >= gradeScore('XF')) { score += 15; reasons.push('XF — лёгкий износ') }
  else if (gs >= gradeScore('VF')) { score += 8 }

  // Pre-1900 в хорошем грейде — особенно ценно
  if (year && year < 1800 && gs >= gradeScore('VF')) {
    score += 8; reasons.push(`${year} г. в VF+ — исключительная сохранность для возраста`)
  } else if (year && year < 1900 && gs >= gradeScore('XF')) {
    score += 5; reasons.push(`XF для XIX века — высокая сохранность`)
  }

  // ══════════════════════════════════
  // 2. MATERIAL & MELT VALUE (max ~25 pts)
  // Серебро/золото = «пол» стоимости (melt value floor)
  // Монета не может стоить дешевле металла
  // ══════════════════════════════════
  if (/золото|gold/.test(mat)) {
    score += 22; reasons.push('Золото — надёжный актив')
  } else if (/серебро|silver/.test(mat)) {
    score += 14; reasons.push('Серебро')
    const weightG = parseFloat(details.weight) || 0
    if (weightG > 0 && price > 0) {
      const meltApprox = weightG * SILVER_PRICE_PER_GRAM
      const meltRatio = meltApprox / price
      if (meltRatio > 1.0) { score += 10; reasons.push(`Дешевле стоимости серебра (${Math.round(meltApprox)}₽ металл)!`) }
      else if (meltRatio > 0.7) { score += 5; reasons.push(`Цена близка к стоимости металла (${Math.round(meltRatio * 100)}%)`) }
    }
  } else if (/биллон|billon/.test(mat)) {
    score += 6; reasons.push('Биллон (сплав с серебром)')
  }

  // ══════════════════════════════════
  // 3. AGE & HISTORICAL SIGNIFICANCE (max ~20 pts)
  // Античные монеты: 8-15% годового роста (numisdon.com)
  // Чем старше + лучше сохранность = экспоненциально ценнее
  // ══════════════════════════════════
  if (year && year < 0) { score += 20; reasons.push(`${Math.abs(year)} г. до н.э. — античная монета`) }
  else if (age > 500) { score += 18; reasons.push(`${year} г. — старше 500 лет`) }
  else if (age > 300) { score += 14; reasons.push(`${year} г. — старше 300 лет`) }
  else if (age > 200) { score += 10; reasons.push(`${year} г. — старше 200 лет`) }
  else if (age > 100) { score += 6; reasons.push(`${year} г. — старше 100 лет`) }
  else if (age > 50) { score += 2 }

  // ══════════════════════════════════
  // 4. RUSSIAN PREMIUM PERIODS (max ~12 pts)
  // Forbes.ru: царские монеты растут 10-15% в год
  // zolotoy-zapas.ru: Николай II — самые востребованные
  // numizmatik.ru: Смутное время — редчайший период
  // ══════════════════════════════════
  // Николай II (1894-1917) — культовый, всегда в спросе
  if (year >= 1894 && year <= 1917 && /николай|nikolay/.test(name)) {
    score += 6; reasons.push('Николай II — всегда востребован коллекционерами')
  }
  // Александр II (1855-1881) — растущий спрос
  if (year >= 1855 && year <= 1881 && /александр|aleksandr|alexandr/.test(name)) {
    score += 5; reasons.push('Александр II — растущий спрос')
  }
  // Александр III (1881-1894)
  if (year >= 1881 && year <= 1894 && /александр\s*iii|aleksandr/.test(name)) {
    score += 5; reasons.push('Александр III — короткое правление, мало монет')
  }
  // Пётр I (1682-1725)
  if (/пётр|петр|peter|petr/.test(name) && year >= 1682 && year <= 1725) {
    score += 6; reasons.push('Пётр I — реформатор, высокий коллекционный спрос')
  }
  // Смутное время
  if (/смутн|1610|1611|1612|владислав|лжедмитрий/.test(name)) {
    score += 10; reasons.push('Смутное время — редчайший период')
  }
  // Медный бунт
  if (/медный бунт|1654|1655|1656/.test(name) && /алексей|aleksey/.test(name)) {
    score += 6; reasons.push('Медный бунт — историческое событие')
  }
  // Раннее СССР серебро (1921-1931) — малые тиражи
  if (year >= 1921 && year <= 1931 && /серебро|silver/.test(mat)) {
    score += 7; reasons.push('Раннее СССР серебро — редкие тиражи')
  }
  // Русская Финляндия — отдельная серия, коллекционный спрос
  if (/финлянд|finland/.test(name) && /серебро|silver/.test(mat)) {
    score += 4; reasons.push('Русская Финляндия серебро — узкая серия')
  }
  // СССР 1947, 1958 — не вышли в обращение, крайне редки
  if ((year === 1947 || year === 1958) && /копе|рубл/.test(name)) {
    score += 15; reasons.push(`${year} г. — не выпущена в обращение, раритет`)
  }
  // Пробные монеты
  if (/пробн|проба|pattern|trial|essai/.test(name)) {
    score += 12; reasons.push('Пробная монета — коллекционная элита')
  }
  // Перепутки (чужие заготовки)
  if (/перепутк|на заготовке|wrong planchet|на чужом/.test(name)) {
    score += 10; reasons.push('Перепутка — монета на чужой заготовке')
  }
  // 1 рубль 1924 — недооценён
  if (year === 1924 && /рубл|rubl/.test(name) && /серебро|silver/.test(mat)) {
    score += 5; reasons.push('1 рубль 1924 серебро — недооценён относительно редкости')
  }
  // Георгий Победоносец — самые ликвидные инвестиционные
  if (/георгий победоносец|saint george/.test(name)) {
    score += 6; reasons.push('Георгий Победоносец — максимальная ликвидность')
  }
  // Павел I (1796-1801) — короткое правление, редкие монеты
  if (/павел|pavel|paul/.test(name) && year >= 1796 && year <= 1801) {
    score += 5; reasons.push('Павел I — короткое правление, редкие монеты')
  }
  // Анна Иоанновна (1730-1740) — инвестиционный потенциал
  if (/анна.*иоанн|anna.*ioann/.test(name) && year >= 1730 && year <= 1740) {
    score += 4; reasons.push('Анна Иоанновна — высокий инвестиционный потенциал')
  }
  // Елизавета Петровна (1741-1762) — редкие тиражи
  if (/елизавет|elizabet/.test(name) && year >= 1741 && year <= 1762) {
    score += 4; reasons.push('Елизавета Петровна — редкие тиражи')
  }
  // Екатерина II — особенно сибирские монеты (содержат серебро и золото в меди)
  if (/екатерин|catherine|katarina/.test(name) && year >= 1762 && year <= 1796) {
    score += 4; reasons.push('Екатерина II — популярный период')
  }
  if (/сибирск|suzun|сузун/.test(name)) {
    score += 6; reasons.push('Сибирская монета — медь с примесью серебра и золота')
  }
  // Полтина/полтинник серебро — крупный номинал, ликвиден
  if (/полтин|poltina/.test(name) && /серебро|silver/.test(mat)) {
    score += 4; reasons.push('Полтина серебро — ликвидный крупный номинал')
  }
  // Рубль серебро Империя — самый коллекционируемый номинал
  if (/\b1\s*рубл|рубль\b/.test(name) && /серебро|silver/.test(mat) && year >= 1700 && year <= 1917) {
    score += 5; reasons.push('Серебряный рубль Империи — топ коллекционного спроса')
  }
  // Набор/серия — завершение серии создаёт давление на цену ключевых дат
  if (/ключев.*дат|key date/.test(name)) {
    score += 6; reasons.push('Ключевая дата серии — давление коллекционеров')
  }
  // Rainbow toning — премия до x4 для серебра
  if (/rainbow|радуж|тонир|toning|toned/.test(name)) {
    score += 3; reasons.push('Тонировка — возможная премия')
  }

  // ══════════════════════════════════
  // 5. WORLD COINS PREMIUMS
  // Античные римские/греческие: 8-12% рост в год
  // Лунные серии, Панды: сильный рост
  // Британские соверены: стабильный спрос
  // ══════════════════════════════════
  if (/римск|roman|антониниан|денарий|denari|follis/.test(name)) {
    score += 5; reasons.push('Римская империя — растущий глобальный рынок')
  }
  if (/греч|greek|драхм|drachm|обол|obol|тетра|tetra/.test(name)) {
    score += 6; reasons.push('Древняя Греция — 15% рост в год')
  }
  if (/боспор|пантикапей|panticap|bospor/.test(name)) {
    score += 5; reasons.push('Боспорское царство — крымская античность')
  }
  // Византия — золото/серебро доступно, рост 8-12%
  if (/визант|byzant|фоллис|follis|солид|solidus|tremissis/.test(name)) {
    score += 5; reasons.push('Византия — доступная античная империя')
    if (/золото|gold|солид|solidus/.test(name + ' ' + mat)) {
      score += 4; reasons.push('Византийское золото — пол стоимости металла')
    }
  }
  if (/соверен|sovereign/.test(name)) {
    score += 4; reasons.push('Соверен — стабильный глобальный спрос')
  }
  // Османские/Сефевидские — недооценены, растущий интерес
  if (/осман|ottoman|султан|sultan|пара\b|акче|akce/.test(name)) {
    score += 4; reasons.push('Османская империя — недооценённый сегмент')
  }
  if (/сефевид|safavid|шахи|shahi|аббаси|abbasi/.test(name)) {
    score += 5; reasons.push('Сефевиды — редкий исламский регион')
  }
  // Китайские Панды — сильный рост, особенно ранние
  if (/панда|panda/.test(name)) {
    score += 5; reasons.push('Панда — растущий азиатский рынок')
  }
  // Японские монеты — бум азиатской нумизматики
  if (/япон|japan|\bмон\s+япон|\d+\s*мон\b|сен\b|иен|yen/.test(name) && age > 100) {
    score += 4; reasons.push('Японская монета — растущий рынок Азии')
  }
  // Талеры — крупное серебро, всегда ликвидны
  if (/талер|thaler|taler/.test(name)) {
    score += 5; reasons.push('Талер — ликвидное крупное серебро')
  }
  // Серия ЧЯП (10 руб биметалл) — Чечня, ЯНАО, Пермский край
  if (/чеченск|ямало|пермский край/.test(name) && /10 руб/.test(name)) {
    score += 10; reasons.push('Серия ЧЯП — малотиражная, высокий спрос')
  }
  // 1 рубль 1997/1998 ММД широкий кант
  if (/широк.*кант|1997.*ммд|1998.*ммд/.test(name) && /рубл/.test(name)) {
    score += 8; reasons.push('Широкий кант — редкий производственный вариант')
  }
  // Лунные серии (Австралия, Великобритания)
  if (/lunar|лунн/.test(name)) {
    score += 4; reasons.push('Лунная серия — коллекционный рост')
  }

  // ══════════════════════════════════
  // 6. ERROR COINS / VARIETIES (max ~15 pts)
  // PCGS: ошибки чеканки — отдельная ценная категория
  // Перепутки штемпелей, двойной удар, смещение
  // ══════════════════════════════════
  if (/ошибк|брак|перечекан|overstr|error|double|двойной удар|смещен|раскол|перепутк|выкус|залипух|непрочекан|гурт на канте|двойной кант/.test(name)) {
    score += 12; reasons.push('Брак/ошибка чеканки — коллекционная редкость')
  }
  // Двойной аверс/реверс — особо ценный брак
  if (/двойной аверс|двойной реверс|без реверса|без аверса|mule/.test(name)) {
    score += 15; reasons.push('Мул/двойной аверс — крайне редкий брак')
  }
  // Без знака монетного двора — редкость
  if (/без знака|без букв|no mint mark|безбуквен/.test(name)) {
    score += 8; reasons.push('Без знака МД — редкий вариант')
  }
  // Ефимок / надчеканка — нумизматическая элита
  if (/ефимок|efimok|надчеканк|counterstamp|countermark/.test(name)) {
    score += 10; reasons.push('Ефимок/надчеканка — редкость, высокий спрос')
  }

  // ══════════════════════════════════
  // 7. PRICE EFFICIENCY (max ~12 pts)
  // Главное правило: покупай самую редкую монету в лучшем
  // состоянии за доступную цену
  // ══════════════════════════════════
  if (coin.old_price && coin.old_price > coin.price) {
    const discount = Math.round((coin.old_price - coin.price) / coin.old_price * 100)
    if (discount >= 30) { score += 8; reasons.push(`Скидка ${discount}% — сильное снижение`) }
    else if (discount >= 15) { score += 5; reasons.push(`Скидка ${discount}%`) }
    else if (discount >= 10) { score += 2; reasons.push(`Скидка ${discount}%`) }
  }
  // AU+ за мало денег
  if (gs >= gradeScore('AU') && price <= 500) { score += 6; reasons.push('AU+ дешевле 500₽ — ниже рынка') }
  else if (gs >= gradeScore('UNC') && price <= 1000) { score += 4; reasons.push('UNC до 1000₽ — выгодная цена') }
  // Общий бонус за дешевизну при хорошем грейде
  if (gs >= gradeScore('VF') && price > 0 && price <= 3000) {
    score += Math.max(0, Math.round((3000 - price) / 400))
  }

  // ══════════════════════════════════
  // 8. NEGATIVE FACTORS (penalties)
  // ══════════════════════════════════
  // Массовые юбилейные СССР (не Proof, не драгмет) — нет вторичного рынка
  if (/юбилейн|памятн|commemorat/.test(name) && year >= 1965 && year <= 1991) {
    if (!/серебро|silver|золото|gold/.test(mat) && gs < gradeScore('Proof')) {
      score -= 12; reasons.push('Массовые юбилейные СССР — низкий потенциал роста')
    }
  }
  // Современные памятные монеты ЦБ РФ (не драгмет)
  if (year >= 2000 && /памятн|юбилейн/.test(name) && !/серебро|silver|золото|gold/.test(mat)) {
    score -= 8; reasons.push('Современные памятные — слабый вторичный рынок')
  }
  // Копии, жетоны, сувениры
  if (/современн|сувенир|копия|replica|жетон|token|новодел/.test(name)) {
    score -= 20; reasons.push('Не оригинальная монета')
  }
  // Чищеные монеты — резко теряют ценность (50-90% потеря по данным PCGS)
  if (/чищен|cleaned|полиров|polish/.test(name) || /чищен|cleaned/.test(details.grade || '')) {
    score -= 10; reasons.push('Чищеная монета — потеря 50-90% ценности')
  }
  // Патина на медных монетах — повышает ценность если оригинальная
  if (/патин|patina|оригинальн.*поверхн/.test(name) && /медь|copper|бронз/.test(mat)) {
    score += 3; reasons.push('Оригинальная патина — ценность для коллекционеров')
  }
  // Проволочные монеты (чешуя) — подделки распространены, но оригиналы ценны
  if (/проволочн|чешуя|wire money/.test(name)) {
    score -= 2; reasons.push('Проволочная монета — высокий риск подделки')
  }
  // Медно-никель без особой ценности (массовый чекан после 1950)
  if (/медно.?никел|cupronickel|cu-ni|мельхиор/.test(mat) && year >= 1950 && gs < gradeScore('Proof')) {
    if (!/ошибк|брак|error|редк/.test(name)) {
      score -= 4
    }
  }

  return { score: Math.round(Math.max(0, score)), reasons }
}

// ─── SSE progress ───
const scanClients = new Set()
function emitProgress(data) {
  const msg = `data: ${JSON.stringify(data)}\n\n`
  for (const res of scanClients) {
    res.write(msg)
  }
}

// ─── Scan logic ───
async function runScan() {
  console.log('[scan] Starting full scan...')
  emitProgress({ stage: 'start', message: 'Начинаю сканирование...' })
  const logStmt = db.prepare('INSERT INTO scan_log (status) VALUES (?)')
  const { lastInsertRowid: scanId } = logStmt.run('running')

  let totalNew = 0
  let totalRemoved = 0
  let detailsParsed = 0

  try {
    // Fetch all feeds
    const allItems = []
    const loadedFeeds = new Set() // track which feeds loaded successfully
    for (let fi = 0; fi < FEEDS.length; fi++) {
      const feed = FEEDS[fi]
      emitProgress({ stage: 'feed', message: `Загрузка фида ${feed.name}...`, feedIndex: fi, feedTotal: FEEDS.length })
      let feedLoaded = false
      for (let attempt = 1; attempt <= 3; attempt++) {
        try {
          const items = await fetchFeed(feed)
          if (items.length > 0) {
            for (let j = 0; j < items.length; j++) allItems.push(items[j])
            loadedFeeds.add(feed.id)
            saveFeedCache(feed.id, items)
            emitProgress({ stage: 'feed_done', message: `${feed.name}: ${items.length.toLocaleString()} товаров`, feedIndex: fi })
            feedLoaded = true
            break
          } else if (attempt < 3) {
            emitProgress({ stage: 'feed_error', message: `${feed.name}: пустой ответ, повтор ${attempt + 1}/3 через 10 сек...`, feedIndex: fi })
            await new Promise(r => setTimeout(r, 10000))
          }
        } catch (e) {
          if (attempt < 3) {
            emitProgress({ stage: 'feed_error', message: `${feed.name}: ${e.message}, повтор ${attempt + 1}/3 через 10 сек...`, feedIndex: fi })
            await new Promise(r => setTimeout(r, 10000))
          }
        }
      }
      // Fallback to disk cache if all attempts failed
      if (!feedLoaded) {
        const cached = loadFeedCache(feed.id)
        if (cached && cached.length) {
          for (let j = 0; j < cached.length; j++) allItems.push(cached[j])
          loadedFeeds.add(feed.id)
          emitProgress({ stage: 'feed_done', message: `${feed.name}: ${cached.length.toLocaleString()} товаров (из кеша)`, feedIndex: fi })
        } else {
          emitProgress({ stage: 'feed_error', message: `${feed.name}: не удалось загрузить, кеш отсутствует`, feedIndex: fi })
        }
      }
      await new Promise(r => setTimeout(r, 3000))
    }

    // Get existing IDs
    const existingIds = new Set(db.prepare('SELECT id FROM coins').all().map(r => r.id))
    const newIds = new Set(allItems.map(i => i.id))

    // Upsert coins
    const upsert = db.prepare(`
      INSERT INTO coins (id, feed, name, price, old_price, url, image, category, available, last_seen)
      VALUES (@id, @feed, @name, @price, @old_price, @url, @image, @category, @available, datetime('now'))
      ON CONFLICT(id) DO UPDATE SET
        price = @price, old_price = @old_price, available = @available,
        last_seen = datetime('now'), name = @name, url = @url, image = @image
    `)
    // Batch in chunks of 5000 to avoid stack overflow
    const CHUNK = 5000
    const totalChunks = Math.ceil(allItems.length / CHUNK)
    const upsertMany = db.transaction((items) => {
      for (const item of items) upsert.run(item)
    })
    for (let i = 0; i < allItems.length; i += CHUNK) {
      const chunkNum = Math.floor(i / CHUNK) + 1
      emitProgress({ stage: 'saving', message: `Сохранение монет: ${Math.min(i + CHUNK, allItems.length).toLocaleString()} / ${allItems.length.toLocaleString()}`, current: chunkNum, total: totalChunks })
      upsertMany(allItems.slice(i, i + CHUNK))
    }

    // Upsert details from feed data (grade, material, weight etc.)
    emitProgress({ stage: 'saving', message: `Сохранение деталей: 0 / ${allItems.length.toLocaleString()}` })
    const detailFromFeed = db.prepare(`
      INSERT INTO coin_details (coin_id, grade, material, weight, diameter, year_from, year_to, in_stock, parsed_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
      ON CONFLICT(coin_id) DO UPDATE SET
        grade = CASE WHEN excluded.grade != '' THEN excluded.grade ELSE coin_details.grade END,
        material = CASE WHEN excluded.material != '' THEN excluded.material ELSE coin_details.material END,
        weight = CASE WHEN excluded.weight != '' THEN excluded.weight ELSE coin_details.weight END,
        diameter = CASE WHEN excluded.diameter != '' THEN excluded.diameter ELSE coin_details.diameter END,
        year_from = CASE WHEN excluded.year_from IS NOT NULL THEN excluded.year_from ELSE coin_details.year_from END,
        in_stock = excluded.in_stock,
        parsed_at = datetime('now')
    `)
    const feedDetailBatch = db.transaction((items) => {
      for (const item of items) {
        if (!item.grade && !item.material && !item.year) continue
        const yearNum = parseInt(item.year) || null
        const mat = item.material || guessMaterial(item.name)
        detailFromFeed.run(item.id, item.grade, mat, item.weight, item.diameter, yearNum, yearNum, item.available)
      }
    })
    for (let i = 0; i < allItems.length; i += CHUNK) {
      emitProgress({ stage: 'saving', message: `Сохранение деталей: ${Math.min(i + CHUNK, allItems.length).toLocaleString()} / ${allItems.length.toLocaleString()}`, current: Math.floor(i / CHUNK) + 1, total: totalChunks })
      feedDetailBatch(allItems.slice(i, i + CHUNK))
    }

    // Count new
    for (const item of allItems) {
      if (!existingIds.has(item.id)) totalNew++
    }

    // Record price history — track price changes
    emitProgress({ stage: 'saving', message: 'Запись истории цен...' })
    const prevPrices = new Map()
    db.prepare('SELECT id, price FROM coins').all().forEach(r => prevPrices.set(r.id, r.price))
    const insertHistory = db.prepare('INSERT INTO price_history (coin_id, price) VALUES (?, ?)')
    const historyBatch = db.transaction((items) => {
      for (const item of items) {
        const prev = prevPrices.get(item.id)
        // Record if: new coin, or price changed
        if (prev === undefined || (prev !== item.price && item.price > 0)) {
          insertHistory.run(item.id, item.price)
        }
      }
    })
    for (let i = 0; i < allItems.length; i += CHUNK) {
      historyBatch(allItems.slice(i, i + CHUNK))
    }

    // Mark removed — only for feeds that loaded successfully
    const markUnavailable = db.prepare('UPDATE coins SET available = 0 WHERE id = ?')
    const existingWithFeed = db.prepare('SELECT id, feed FROM coins').all()
    for (const row of existingWithFeed) {
      if (loadedFeeds.has(row.feed) && !newIds.has(row.id)) {
        markUnavailable.run(row.id)
        totalRemoved++
      }
    }

    // Details are now extracted directly from feed data (grade, material, weight)
    // No need to parse individual product pages

    db.prepare('UPDATE scan_log SET finished_at = datetime(\'now\'), new_coins = ?, removed_coins = ?, details_parsed = ?, status = ? WHERE id = ?')
      .run(totalNew, totalRemoved, detailsParsed, 'done', scanId)

    emitProgress({ stage: 'done', message: `Готово! Новых: ${totalNew}, удалено: ${totalRemoved}, деталей: ${detailsParsed}`, totalNew, totalRemoved, detailsParsed })
    console.log(`[scan] Done. New: ${totalNew}, Removed: ${totalRemoved}, Parsed: ${detailsParsed}`)
  } catch (e) {
    console.error(`[scan] Error: ${e.message}`)
    emitProgress({ stage: 'error', message: `Ошибка: ${e.message}` })
    db.prepare('UPDATE scan_log SET finished_at = datetime(\'now\'), status = ? WHERE id = ?')
      .run('error: ' + e.message, scanId)
  }

  return { totalNew, totalRemoved, detailsParsed }
}

// ─── Settings helpers ───
function getSettings() {
  const rows = db.prepare('SELECT key, value FROM settings').all()
  return Object.fromEntries(rows.map(r => [r.key, r.value]))
}

// ─── API routes ───

// Get hot coins (filtered & scored)
app.get('/api/coins', (req, res) => {
  const settings = getSettings()
  const maxPrice = parseFloat(req.query.max_price || settings.max_price) || 3000
  const minGrade = req.query.min_grade || settings.min_grade || 'VF'
  const materialFilter = (req.query.material || settings.preferred_material || '').toLowerCase()
  const onlyInStock = req.query.in_stock !== '0'
  const feedFilter = req.query.feed || ''
  const limit = parseInt(req.query.limit) || 200
  const offset = parseInt(req.query.offset) || 0

  let coins
  if (feedFilter) {
    coins = db.prepare(`
      SELECT c.*, d.grade, d.material, d.weight, d.diameter, d.year_from, d.year_to, d.country, d.in_stock, d.parsed_at
      FROM coins c LEFT JOIN coin_details d ON d.coin_id = c.id
      WHERE c.available = 1 AND c.price > 0 AND c.price <= ? AND c.feed = ?
      ORDER BY c.first_seen DESC
    `).all(maxPrice, feedFilter)
  } else {
    coins = db.prepare(`
      SELECT c.*, d.grade, d.material, d.weight, d.diameter, d.year_from, d.year_to, d.country, d.in_stock, d.parsed_at
      FROM coins c LEFT JOIN coin_details d ON d.coin_id = c.id
      WHERE c.available = 1 AND c.price > 0 AND c.price <= ?
      ORDER BY c.first_seen DESC
    `).all(maxPrice)
  }

  // Filter out non-coins (albums, capsules, postcards etc.)
  coins = coins.filter(c => isCoin(c.name))

  // Filter by grade
  if (minGrade) {
    coins = coins.filter(c => c.grade && gradeAtLeast(c.grade, minGrade))
  }

  // Filter by stock
  if (onlyInStock) {
    coins = coins.filter(c => c.in_stock === 1 || c.in_stock === null)
  }

  // Enrich material from name if not parsed
  coins = coins.map(c => ({
    ...c,
    material: c.material || guessMaterial(c.name),
  }))

  // Filter by material
  if (materialFilter && materialFilter !== 'любой') {
    const mats = materialFilter.split(',').map(m => m.trim())
    coins = coins.filter(c => {
      if (!c.material) return false
      const cm = c.material.toLowerCase()
      return mats.some(m => cm.includes(m))
    })
  }

  // Score & sort with reasons
  coins = coins.map(c => {
    const { score, reasons } = investmentScore(c, {
      grade: c.grade, material: c.material,
      year_from: c.year_from, year_to: c.year_to,
      weight: c.weight,
    })
    const summary = buildSummary(c, { grade: c.grade, material: c.material, year_from: c.year_from, weight: c.weight }, score, reasons)
    return { ...c, score, reasons, summary: summary.short, analysis: summary.full, emailCopy: summary.email }
  }).sort((a, b) => b.score - a.score)

  // Deduplicate: group by name+price+grade+material, keep first (highest score)
  const hideDupes = req.query.hide_dupes !== '0' && (req.query.hide_dupes === '1' || settings.hide_dupes === '1')
  if (hideDupes) {
    const seen = new Set()
    coins = coins.filter(c => {
      const key = `${c.name}|${c.price}|${c.grade}|${c.material}`
      if (seen.has(key)) return false
      seen.add(key)
      return true
    })
  }

  const total = coins.length
  coins = coins.slice(offset, offset + limit)

  res.json({ coins, total, filters: { maxPrice, minGrade, material: materialFilter, onlyInStock, hideDupes } })
})

// Get all coins (unfiltered, for browsing)
app.get('/api/coins/all', (req, res) => {
  const limit = parseInt(req.query.limit) || 100
  const offset = parseInt(req.query.offset) || 0
  const coins = db.prepare(`
    SELECT c.*, d.grade, d.material, d.in_stock, d.parsed_at
    FROM coins c
    LEFT JOIN coin_details d ON d.coin_id = c.id
    WHERE c.available = 1 AND c.price > 0
    ORDER BY c.price ASC
    LIMIT ? OFFSET ?
  `).all(limit, offset)
  const total = db.prepare('SELECT COUNT(*) as cnt FROM coins WHERE available = 1 AND price > 0').get().cnt
  res.json({ coins, total })
})

// Get new arrivals (coins first seen in last N days)
app.get('/api/coins/new', (req, res) => {
  const days = parseInt(req.query.days) || 7
  const coins = db.prepare(`
    SELECT c.*, d.grade, d.material, d.weight, d.year_from, d.country, d.in_stock
    FROM coins c
    LEFT JOIN coin_details d ON d.coin_id = c.id
    WHERE c.available = 1 AND c.price > 0
      AND c.first_seen >= datetime('now', '-' || ? || ' days')
    ORDER BY c.first_seen DESC
  `).all(days)
  res.json({ coins, total: coins.length })
})

// Settings
app.get('/api/settings', (req, res) => {
  res.json(getSettings())
})
app.put('/api/settings', (req, res) => {
  const upsert = db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?')
  for (const [k, v] of Object.entries(req.body)) {
    upsert.run(k, String(v), String(v))
  }
  res.json({ ok: true })
})

// Trigger manual scan
let scanRunning = false
app.post('/api/scan', async (req, res) => {
  if (scanRunning) return res.json({ status: 'already_running' })
  scanRunning = true
  res.json({ status: 'started' })
  try {
    await runScan()
  } finally {
    scanRunning = false
  }
})

// SSE scan progress
app.get('/api/scan/progress', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  })
  scanClients.add(res)
  req.on('close', () => scanClients.delete(res))
})

// Scan status
app.get('/api/scan/status', (req, res) => {
  const last = db.prepare('SELECT * FROM scan_log ORDER BY id DESC LIMIT 1').get()
  res.json({ running: scanRunning, last: last || null })
})

// Stats
// ─── Dashboard stats ───
app.get('/api/stats', (req, res) => {
  const total = db.prepare('SELECT COUNT(*) as cnt FROM coins WHERE available = 1').get().cnt
  const withDetails = db.prepare('SELECT COUNT(*) as cnt FROM coin_details WHERE parsed_at IS NOT NULL').get().cnt
  const feeds = db.prepare('SELECT feed, COUNT(*) as cnt FROM coins WHERE available = 1 GROUP BY feed').all()
  res.json({ total, withDetails, feeds })
})

app.get('/api/dashboard', (req, res) => {
  const total = db.prepare('SELECT COUNT(*) as cnt FROM coins WHERE available = 1').get().cnt
  const feeds = db.prepare('SELECT feed, COUNT(*) as cnt, ROUND(AVG(price),0) as avg_price FROM coins WHERE available = 1 AND price > 0 GROUP BY feed').all()

  // New in last 7 days
  const newThisWeek = db.prepare("SELECT COUNT(*) as cnt FROM coins WHERE first_seen >= datetime('now', '-7 days')").get().cnt

  // Price drops (coins where current price < previous recorded price)
  const priceDrops = db.prepare(`
    SELECT c.id, c.name, c.feed, c.price, c.url, c.image, ph.price as prev_price,
      ROUND((ph.price - c.price) / ph.price * 100) as drop_pct
    FROM coins c
    JOIN (
      SELECT coin_id, price, ROW_NUMBER() OVER (PARTITION BY coin_id ORDER BY recorded_at DESC) as rn
      FROM price_history
    ) ph ON ph.coin_id = c.id AND ph.rn = 2
    WHERE c.available = 1 AND c.price < ph.price AND c.price > 0
    ORDER BY drop_pct DESC
    LIMIT 20
  `).all()

  // Price rises
  const priceRises = db.prepare(`
    SELECT c.id, c.name, c.feed, c.price, c.url, ph.price as prev_price,
      ROUND((c.price - ph.price) / ph.price * 100) as rise_pct
    FROM coins c
    JOIN (
      SELECT coin_id, price, ROW_NUMBER() OVER (PARTITION BY coin_id ORDER BY recorded_at DESC) as rn
      FROM price_history
    ) ph ON ph.coin_id = c.id AND ph.rn = 2
    WHERE c.available = 1 AND c.price > ph.price AND c.price > 0
    ORDER BY rise_pct DESC
    LIMIT 20
  `).all()

  // Recently disappeared (was available, now not)
  const disappeared = db.prepare(`
    SELECT id, name, feed, price, url FROM coins
    WHERE available = 0 AND last_seen >= datetime('now', '-7 days')
    ORDER BY last_seen DESC LIMIT 20
  `).all()

  // Material breakdown
  const materials = db.prepare(`
    SELECT d.material, COUNT(*) as cnt, ROUND(AVG(c.price),0) as avg_price
    FROM coin_details d JOIN coins c ON c.id = d.coin_id
    WHERE c.available = 1 AND d.material != '' AND c.price > 0
    GROUP BY d.material ORDER BY cnt DESC LIMIT 15
  `).all()

  // Grade breakdown
  const grades = db.prepare(`
    SELECT d.grade, COUNT(*) as cnt, ROUND(AVG(c.price),0) as avg_price
    FROM coin_details d JOIN coins c ON c.id = d.coin_id
    WHERE c.available = 1 AND d.grade != '' AND c.price > 0
    GROUP BY d.grade ORDER BY cnt DESC LIMIT 15
  `).all()

  res.json({ total, feeds, newThisWeek, priceDrops, priceRises, disappeared, materials, grades })
})

// ─── Price history for a coin ───
app.get('/api/coins/:id/history', (req, res) => {
  const history = db.prepare('SELECT price, recorded_at FROM price_history WHERE coin_id = ? ORDER BY recorded_at ASC').all(req.params.id)
  res.json({ history })
})

// ─── Cross-store comparison ───
app.get('/api/compare', (req, res) => {
  // Find coins with same name across different feeds
  const dupes = db.prepare(`
    SELECT c1.name, c1.feed as feed1, c1.price as price1, c1.url as url1,
           c2.feed as feed2, c2.price as price2, c2.url as url2,
           ROUND(ABS(c1.price - c2.price) / MAX(c1.price, c2.price) * 100) as diff_pct
    FROM coins c1
    JOIN coins c2 ON c1.name = c2.name AND c1.feed < c2.feed
    WHERE c1.available = 1 AND c2.available = 1 AND c1.price > 0 AND c2.price > 0
    ORDER BY diff_pct DESC
    LIMIT 50
  `).all()
  res.json({ comparisons: dupes })
})

// Parse details for a specific coin (manual)
app.post('/api/coins/:id/parse', async (req, res) => {
  const coin = db.prepare('SELECT * FROM coins WHERE id = ?').get(req.params.id)
  if (!coin) return res.status(404).json({ error: 'not found' })
  const details = await parseProductPage(coin.url)
  if (!details) return res.status(500).json({ error: 'parse failed' })
  db.prepare(`
    INSERT INTO coin_details (coin_id, grade, material, weight, diameter, year_from, year_to, country, in_stock, parsed_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
    ON CONFLICT(coin_id) DO UPDATE SET
      grade = ?, material = ?, weight = ?, diameter = ?, year_from = ?, year_to = ?, country = ?, in_stock = ?, parsed_at = datetime('now')
  `).run(coin.id, details.grade, details.material, details.weight, details.diameter, details.year_from, details.year_to, details.country, details.in_stock,
    details.grade, details.material, details.weight, details.diameter, details.year_from, details.year_to, details.country, details.in_stock)
  res.json({ ok: true, details })
})

// ─── Cron ───
const settings = getSettings()
const hour = parseInt(settings.auto_scan_hour) || 8
cron.schedule(`0 ${hour} * * *`, async () => {
  const s = getSettings()
  if (s.scan_enabled !== '1') return
  console.log('[cron] Scheduled scan starting...')
  if (!scanRunning) {
    scanRunning = true
    try { await runScan() } finally { scanRunning = false }
  }
})

// ─── Start ───
app.listen(PORT, () => {
  console.log(`Coin Scout running on http://localhost:${PORT}`)
  console.log(`Database: ${DB_PATH}`)
  console.log(`Feeds: ${FEEDS.map(f => f.name).join(', ')}`)
  console.log(`Auto-scan: daily at ${hour}:00`)
})
