import React, { useState, useEffect, useMemo } from 'react'

const STORAGE_ENEMIES = 'dw:enemies:v1'

// Nine characteristics, each with a colour family for the stat chips.
const STAT_DEFS = [
  { key: 'ws',  label: 'WS',  ring: 'border-rose-500/40',    text: 'text-rose-300' },
  { key: 'bs',  label: 'BS',  ring: 'border-amber-500/40',   text: 'text-amber-300' },
  { key: 's',   label: 'S',   ring: 'border-orange-500/40',  text: 'text-orange-300' },
  { key: 't',   label: 'T',   ring: 'border-lime-500/40',    text: 'text-lime-300' },
  { key: 'ag',  label: 'Ag',  ring: 'border-emerald-500/40', text: 'text-emerald-300' },
  { key: 'int', label: 'Int', ring: 'border-cyan-500/40',    text: 'text-cyan-300' },
  { key: 'per', label: 'Per', ring: 'border-sky-500/40',     text: 'text-sky-300' },
  { key: 'wp',  label: 'WP',  ring: 'border-violet-500/40',  text: 'text-violet-300' },
  { key: 'fel', label: 'Fel', ring: 'border-fuchsia-500/40', text: 'text-fuchsia-300' },
]

const BOOK_ABBR = {
  'Mark of the Xenos': 'MoX',
  'First Founding': 'FF',
  'Deathwatch Core Rulebook': 'CR',
  'The Emperor Protects': 'TEP',
  'Rites of Battle': 'RoB',
}
const BOX_TONES = {
  wound: 'bg-rose-950/70 border-rose-700/70 text-rose-100',
  tough: 'bg-lime-950/60 border-lime-700/60 text-lime-100',
  armour: 'bg-yellow-950/60 border-yellow-700/60 text-yellow-100',
  move: 'bg-cyan-950/60 border-cyan-700/60 text-cyan-100',
  weapon: 'bg-red-950/60 border-red-700/60 text-red-100',
  skill: 'bg-sky-950/60 border-sky-700/60 text-sky-100',
  talent: 'bg-purple-950/60 border-purple-700/60 text-purple-100',
  trait: 'bg-fuchsia-950/60 border-fuchsia-700/60 text-fuchsia-100',
  gear: 'bg-emerald-950/60 border-emerald-700/60 text-emerald-100',
  source: 'bg-slate-950/60 border-slate-600 text-slate-100',
}

function trimText(value, max = 120) {
  const text = String(value || '').replace(/\s+/g, ' ').trim()
  if (!text) return null
  return text.length > max ? text.slice(0, max).trimEnd() : text
}

function firstValue(value, max = 56) {
  const text = trimText(value, max * 2)
  if (!text) return null
  const parts = text.split(/\s*[;,]\s*/).filter(Boolean)
  return trimText(parts[0] || text, max)
}

function bookAbbr(book) {
  return BOOK_ABBR[book] || trimText(book, 14) || ''
}

function splitTopLevel(value) {
  const items = []
  let current = ''
  let depth = 0
  for (const ch of String(value || '')) {
    if (ch === '(') depth += 1
    if (ch === ')' && depth > 0) depth -= 1
    if (ch === ',' && depth === 0) {
      if (current.trim()) items.push(current.trim())
      current = ''
    } else {
      current += ch
    }
  }
  if (current.trim()) items.push(current.trim())
  return items
}

function shortWeaponName(value) {
  return String(value || '')
    .replace(/^Astartes\s+/i, '')
    .replace(/\s*\(.*/, '')
    .replace(/\bor\b.*/i, '')
    .trim()
}

function qualityAbbr(value) {
  const text = String(value || '')
  const qualities = [
    ['Tearing', 'Tear'],
    ['Balanced', 'Bal'],
    ['Toxic', 'Tox'],
    ['Razor Sharp', 'Razor'],
    ['Unbalanced', 'Unbal'],
    ['Snare', 'Snare'],
    ['Blast', 'Blast'],
    ['Flame', 'Flame'],
    ['Felling', 'Fell'],
    ['Devastating', 'Dev'],
    ['Accurate', 'Acc'],
    ['Inaccurate', 'Inacc'],
    ['Unreliable', 'Unrel'],
  ]
  return qualities
    .filter(([needle]) => text.toLowerCase().includes(needle.toLowerCase()))
    .map(([, abbr]) => abbr)
    .slice(0, 3)
    .join('/')
}

function shortDamage(value) {
  const damage = String(value || '').replace(/\s+/g, '')
  return damage.replace(/^1d/i, 'd')
}

function parseWeaponAttack(item, profile) {
  const damage = item.match(/\b\d+d\d+(?:\s*[+-]\s*\d+)?\s*[EIRX]?\b/i)?.[0]?.replace(/\s+/g, '')
  if (!damage) return null
  const pen = item.match(/\bPen(?:etration)?\s*(\d+)/i)?.[1]
  const ranged = /\b\d+\s*m\b/i.test(item) || /\bS\s*\/\s*[-\d]/i.test(item)
  const stat = ranged ? `BS ${profile?.bs ?? '-'}` : `WS ${profile?.ws ?? '-'}`
  const mode = ranged ? 'R' : 'M'
  const name = trimText(shortWeaponName(item), 15) || 'Atk'
  const qualities = qualityAbbr(item)
  return {
    mode,
    stat,
    name,
    damage: shortDamage(damage),
    pen,
    qualities,
  }
}

function parsedWeaponAttacks(weapons, profile) {
  return splitTopLevel(weapons)
    .map(item => parseWeaponAttack(item, profile))
    .filter(Boolean)
}

function damageStatLines(weapons, profile, maxLines = 4) {
  const attacks = parsedWeaponAttacks(weapons, profile)
  if (!attacks.length) {
    const summary = String(weapons || '').match(/\b\d+d\d+(?:\s*[+-]\s*\d+)?\s*[EIRX]?\b/i)?.[0]
    return summary ? [`M/R ${shortDamage(summary)}`] : []
  }

  const melee = attacks.filter(a => a.mode === 'M')
  const ranged = attacks.filter(a => a.mode === 'R')
  const ordered = [
    ...melee.slice(0, 2),
    ...ranged.slice(0, 2),
    ...attacks.filter(a => !melee.includes(a) && !ranged.includes(a)),
  ].slice(0, maxLines)

  const hidden = attacks.length - ordered.length
  return ordered.map((attack, idx) => {
    const tail = [
      attack.pen ? `P${attack.pen}` : '',
      attack.name,
      attack.qualities,
    ].filter(Boolean).join(' ')
    const more = hidden > 0 && idx === ordered.length - 1 ? ` +${hidden}` : ''
    return `${attack.mode} ${attack.damage}${tail ? ` ${tail}` : ''}${more} (${attack.stat})`
  })
}

function attackLines(weapons, profile, maxLines = 4) {
  const lines = damageStatLines(weapons, profile, maxLines)
  if (lines.length) return lines
  return []
}

// Render armour that may be a number, a string, or an object of locations.
function formatArmour(a){
  const lines = formatArmourLines(a)
  return lines ? lines.join(' / ') : null
}

function formatArmourLines(a){
  if (a == null || a === '') return null

  const normalized = {
    all: null,
    head: null,
    body: null,
    arms: null,
    legs: null,
    leftArm: null,
    rightArm: null,
    leftLeg: null,
    rightLeg: null,
    raw: null,
  }

  const setNum = (key, value) => {
    const n = Number(value)
    if (Number.isFinite(n)) normalized[key] = n
  }

  const parseText = (text) => {
    const value = String(text || '').trim()
    if (!value) return
    if (/^none\.?$/i.test(value)) {
      normalized.raw = 'None'
      return
    }
    const compact = value.toLowerCase().replace(/\s+/g, ' ')
    const matchers = [
      ['all', /\ball\s*(\d+)/i],
      ['head', /\bhead\s*(\d+)/i],
      ['body', /\bbody\s*(\d+)/i],
      ['arms', /\barms?\s*(\d+)/i],
      ['legs', /\blegs?\s*(\d+)/i],
      ['leftArm', /\bleft\s*arm\s*(\d+)/i],
      ['rightArm', /\bright\s*arm\s*(\d+)/i],
      ['leftLeg', /\bleft\s*leg\s*(\d+)/i],
      ['rightLeg', /\bright\s*leg\s*(\d+)/i],
    ]
    for (const [key, regex] of matchers) {
      const match = compact.match(regex)
      if (match) setNum(key, match[1])
    }
    if (normalized.all == null) {
      const generic = value.match(/\b(\d+)\b/)
      if (generic && !normalized.raw) setNum('all', generic[1])
    }
    if (!normalized.raw) normalized.raw = value
  }

  if (typeof a === 'number') {
    normalized.all = a
  } else if (typeof a === 'string') {
    parseText(a)
  } else if (typeof a === 'object') {
    const entries = Object.entries(a)
    for (const [key, value] of entries) {
      if (/^\d+$/.test(key)) continue
      const k = key.toLowerCase()
      if (k === 'all' || k === 'value') setNum('all', value)
      else if (k === 'head') setNum('head', value)
      else if (k === 'body') setNum('body', value)
      else if (k === 'arms' || k === 'arm') setNum('arms', value)
      else if (k === 'legs' || k === 'leg') setNum('legs', value)
      else if (k === 'leftarm' || k === 'la') setNum('leftArm', value)
      else if (k === 'rightarm' || k === 'ra') setNum('rightArm', value)
      else if (k === 'leftleg' || k === 'll') setNum('leftLeg', value)
      else if (k === 'rightleg' || k === 'rl') setNum('rightLeg', value)
      else if (k === 'locations' && Array.isArray(value)) {
        const nums = value.map(v => Number(v)).filter(v => Number.isFinite(v))
        if (nums.length) normalized.all = Math.max(...nums)
      } else if (k === 'type' && !normalized.raw) {
        normalized.raw = String(value || '').trim() || null
      }
    }
    if (normalized.all == null) {
      const fallback = Object.values(a).find(v => Number.isFinite(Number(v)))
      if (fallback != null) normalized.all = Number(fallback)
    }
    if (!normalized.raw) normalized.raw = null
  }

  if (
    normalized.all == null &&
    normalized.head == null &&
    normalized.body == null &&
    normalized.arms == null &&
    normalized.legs == null &&
    normalized.leftArm == null &&
    normalized.rightArm == null &&
    normalized.leftLeg == null &&
    normalized.rightLeg == null
  ) {
    return normalized.raw ? [normalized.raw] : null
  }

  const lines = []
  if (normalized.all != null) {
    lines.push(`All ${normalized.all}`)
    return lines
  }

  const main = []
  if (normalized.head != null) main.push(`H${normalized.head}`)
  if (normalized.body != null) main.push(`B${normalized.body}`)
  if (normalized.arms != null) main.push(`A${normalized.arms}`)
  if (normalized.legs != null) main.push(`L${normalized.legs}`)
  if (main.length) lines.push(main.join(' '))

  const split = []
  if (normalized.leftArm != null || normalized.rightArm != null) {
    if (normalized.leftArm != null && normalized.rightArm != null && normalized.leftArm === normalized.rightArm) {
      split.push(`A${normalized.leftArm}`)
    } else {
      if (normalized.leftArm != null) split.push(`LA${normalized.leftArm}`)
      if (normalized.rightArm != null) split.push(`RA${normalized.rightArm}`)
    }
  }
  if (normalized.leftLeg != null || normalized.rightLeg != null) {
    if (normalized.leftLeg != null && normalized.rightLeg != null && normalized.leftLeg === normalized.rightLeg) {
      split.push(`L${normalized.leftLeg}`)
    } else {
      if (normalized.leftLeg != null) split.push(`LL${normalized.leftLeg}`)
      if (normalized.rightLeg != null) split.push(`RL${normalized.rightLeg}`)
    }
  }
  if (split.length) lines.push(split.join(' '))

  if (!lines.length && normalized.raw) lines.push(normalized.raw)
  return lines.length ? lines : null
}

// Colour a card by threat tier, inferred from Wounds when no explicit tier.
function tierOf(wounds){
  const w = Number(wounds) || 0
  if (w >= 80) return { label: 'Master',  bar: 'bg-rose-600',    ring: 'border-rose-700/60' }
  if (w >= 25) return { label: 'Elite',   bar: 'bg-amber-500',   ring: 'border-amber-700/60' }
  return { label: 'Troops', bar: 'bg-emerald-600', ring: 'border-emerald-700/50' }
}

function tierLabel(d){
  return d.horde ? 'Horde' : (d.threatTier || tierOf(d.wounds).label)
}

function sourceLabel(d){
  if (d.statSource === 'pdf') return 'verified'
  if (d.statSource === 'ocr') return 'ocr'
  if (d.statSource === 'canon') return 'review'
  return d.needsReview ? 'review' : 'ready'
}

function FieldBlock({ label, value, className = '' }){
  const short = trimText(value, 170)
  if (!short) return null
  return (
    <div className={`rounded-md border px-2 py-1.5 min-h-[74px] max-h-[92px] overflow-hidden ${className}`} title={String(value)}>
      <div className="text-[9px] uppercase font-bold tracking-wide opacity-70 leading-none">{label}</div>
      <div className="text-xs text-slate-50 leading-snug mt-1 whitespace-normal break-words overflow-hidden">{short}</div>
    </div>
  )
}

function DamageBlock({ weapons, profile }) {
  const lines = attackLines(weapons, profile)
  if (!lines.length) return null
  return (
    <div className={`rounded-md border px-2 py-1.5 min-h-[92px] overflow-visible ${BOX_TONES.weapon}`} title={String(weapons || '')}>
      <div className="text-[9px] uppercase font-bold tracking-wide opacity-70 leading-none">Dmg</div>
      <div className="text-xs text-slate-50 leading-snug mt-1 space-y-0.5">
        {lines.map(line => <div key={line} className="whitespace-normal break-words">{line}</div>)}
      </div>
    </div>
  )
}

function ArmourBlock({ armour, title }) {
  const lines = formatArmourLines(armour)
  if (!lines || !lines.length) return null
  return (
    <div className={`rounded-md border px-2 py-1.5 min-h-[92px] overflow-visible ${BOX_TONES.armour}`} title={title || String(armour || '')}>
      <div className="text-[9px] uppercase font-bold tracking-wide opacity-70 leading-none">AP</div>
      <div className="text-xs text-slate-50 leading-snug mt-1 space-y-0.5">
        {lines.map(line => <div key={line} className="whitespace-normal break-words">{line}</div>)}
      </div>
    </div>
  )
}

function QuickBox({ label, value, tone, title, wrap = false, className = '' }) {
  return (
    <div className={`rounded-md border px-2 py-1 min-w-[58px] ${wrap ? 'max-w-none min-w-[170px]' : 'max-w-[112px]'} text-center ${BOX_TONES[tone] || BOX_TONES.source} ${className}`} title={title || String(value || '')}>
      <div className="text-[9px] uppercase font-bold opacity-70 leading-none">{label}</div>
      <div className="text-xs font-bold leading-tight mt-0.5 whitespace-normal break-words">{value ?? '—'}</div>
    </div>
  )
}

function StatStrip({ profile }){
  if (!profile) return null
  return (
    <div className="grid grid-cols-9 gap-px rounded-md overflow-hidden border border-slate-700 bg-slate-700">
      {STAT_DEFS.map(s => (
        <div key={s.key} className="bg-slate-950/70 text-center px-1 py-0.5">
          <div className={`text-[9px] font-semibold uppercase ${s.text}`}>{s.label}</div>
          <div className="text-xs font-bold text-white leading-none">{profile[s.key] ?? '—'}</div>
        </div>
      ))}
    </div>
  )
}

function normalizeEntry(en){
  // Consolidate possible shapes so the UI can render consistently.
  const stats = en.stats || {}
  const profile = en.profile || stats.profile || null
  const wounds = en.wounds ?? (stats.wounds !== undefined ? stats.wounds : null)
  const horde = !!(en.horde || stats.horde || /horde/i.test(String(en.name || en.bestiaryName || stats.name || stats.bestiaryName || '')))
  const magnitude = en.magnitude ?? stats.magnitude ?? (horde ? wounds : null)
  
  // Helper to format arrays and objects into readable strings
  const formatField = (field, depth = 0) => {
    if (!field) return null
    if (Array.isArray(field)) {
      return field.map(item => formatField(item, depth + 1)).filter(Boolean).join(', ')
    }
    if (typeof field === 'object') {
      const entries = Object.entries(field)
        .map(([k, v]) => {
          const rendered = formatField(v, depth + 1)
          return rendered ? `${k}: ${rendered}` : null
        })
        .filter(Boolean)
      return entries.length ? entries.join(', ') : JSON.stringify(field)
    }
    return String(field)
  }
  
  return {
    name: en.name || en.bestiaryName || stats.bestiaryName || stats.name || en.pdf || '<no-name>',
    type: en.type || stats.type || '',
    affiliation: en.affiliation || stats.affiliation || '',
    book: en.book || en.source || en.pdf || '',
    pdf: en.pdf || '',
    page: en.page ?? stats.page ?? null,
    profile,
    horde,
    magnitude,
    wounds,
    toughnessBonus: stats.toughnessBonus ?? (profile && profile.t != null ? Math.floor(Number(profile.t) / 10) : null),
    threatTier: stats.threatTier || null,
    statSource: stats.statSource || null,
    needsReview: !!stats.needsReview,
    movement: formatField(en.movement || stats.movement),
    toughness: formatField(en.toughness || stats.toughness),
    armour: formatArmour(en.armour || stats.armour),
    armourLines: formatArmourLines(en.armour || stats.armour),
    skills: formatField(en.skills || stats.skills),
    talents: formatField(en.talents || stats.talents),
    traits: formatField(en.traits || stats.traits),
    weapons: formatField(en.weapons || stats.weapons),
    gear: formatField(en.gear || stats.gear),
    snippet: formatField(en.snippet || stats.snippet),
    stats
  }
}

export default function BestiaryTab({ sessionId }){
  const [q, setQ] = useState('')
  const [tierFilter, setTierFilter] = useState('all')
  const [sourceFilter, setSourceFilter] = useState('all')
  const [expanded, setExpanded] = useState({})
  const [enemies, setEnemies] = useState(() => {
    try{
      const raw = localStorage.getItem(STORAGE_ENEMIES)
      if(!raw) return []
      const parsed = JSON.parse(raw)
      const arr = Array.isArray(parsed) ? parsed : (parsed.results || [])
      return arr.map(normalizeEntry)
    }catch(e){ return [] }
  })
  // Keep a ref to the initial enemies value so we can check it in mount-only effects
  const initialEnemies = React.useRef(enemies)
  // Database / retry UI state
  const WARNING_DISMISS_KEY = 'dw:warning-dismiss-until:v1'
  const [dbDown, setDbDown] = useState(false)
  const [dismissUntil, setDismissUntil] = useState(() => {
    try{ const v = Number(localStorage.getItem(WARNING_DISMISS_KEY)); return isNaN(v) ? 0 : v }catch(e){ return 0 }
  })
  const [isRefreshing, setIsRefreshing] = useState(false)
  const retryRef = React.useRef(null)

  // hydrate from storage on mount
  useEffect(()=>{ try{ const raw = localStorage.getItem(STORAGE_ENEMIES); if(raw){ const parsed = JSON.parse(raw); const arr = Array.isArray(parsed) ? parsed : (parsed.results || []); setEnemies(arr.map(normalizeEntry)) } }catch(e){} },[])

    // loadData attempts to fetch from database API; on failure it will fall back to cache and set dbDown
  async function loadData(force = false){
    setIsRefreshing(true)
    const cacheParam = force ? '?_=' + Date.now() : ''
    
    try {
      // Try database API first
      const response = await fetch(`/api/bestiary/full${cacheParam}`)
      if (response.ok) {
        const data = await response.json()
        if (Array.isArray(data) && data.length > 0) {
          setEnemies(data.map(normalizeEntry))
          localStorage.setItem(STORAGE_ENEMIES, JSON.stringify(data))
          setDbDown(false)
          setIsRefreshing(false)
          console.log(`Loaded ${data.length} enemies from database API`)
          return
        }
      }
    } catch (error) {
      console.error('Database API failed:', error)
    }

  // If we reach here, the database API failed to return usable data.
  // Mark DB as down and fall back to cache/localStorage only.
  setDbDown(true)
  setIsRefreshing(false)
    // Try reading from cache/localStorage
    try{
      const raw = localStorage.getItem(STORAGE_ENEMIES)
      if(raw){
        const parsed = JSON.parse(raw)
        const arr = Array.isArray(parsed) ? parsed : (parsed.results || [])
        if(arr.length>0){
          setEnemies(arr.map(normalizeEntry))
          console.log(`Loaded ${arr.length} enemies from localStorage cache`)
          return
        }
      }
    }catch(e){}

    // No data available
    setEnemies([])
    console.log('No enemy data available — DB and cache are empty')
  }

  async function updateFromDatabase() {
    setIsRefreshing(true)
    try {
      // First try to reload the database
      const reloadResponse = await fetch('/api/bestiary/reload', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-gm-secret': process.env.REACT_APP_GM_SECRET || 'defaultsecret',
          'x-session-id': sessionId || ''
        }
      })
      
      if (reloadResponse.ok) {
        console.log('Database reloaded successfully')
      }
      
      // Then refresh the data (force=true to bypass cache)
      await loadData(true)
    } catch (e) {
      console.error('Update from database failed:', e)
      // Fallback to regular refresh
      await loadData(true)
    }
  }

  useEffect(()=>{
    // Only attempt network load on mount if we have no cached enemies.
    // Use the initialEnemies ref to avoid creating a dependency on `enemies`.
    if(initialEnemies.current && initialEnemies.current.length>0) return
    loadData().catch(()=>{})
    return ()=>{ if(retryRef.current){ clearInterval(retryRef.current); retryRef.current = null } }
  },[])

  const filtered = useMemo(()=>{
    const tierMatches = (e) => tierFilter === 'all' || tierLabel(e).toLowerCase() === tierFilter
    const sourceMatches = (e) => sourceFilter === 'all' || sourceLabel(e) === sourceFilter
    const scoped = enemies.filter(e => tierMatches(e) && sourceMatches(e))
    if(!q) return scoped
    const n = q.toLowerCase()
    return scoped.filter(e => {
      const searchFields = [
        e.name, e.type, e.affiliation, e.book, e.pdf, e.skills, e.talents, e.traits,
        e.weapons, e.gear, e.movement, e.armour, e.snippet
      ]
      return searchFields.some(field => 
        field && String(field).toLowerCase().includes(n)
      )
    })
  },[q,enemies,tierFilter,sourceFilter])

  const counts = useMemo(() => ({
    total: enemies.length,
    shown: filtered.length,
    verified: enemies.filter(e => sourceLabel(e) === 'verified').length,
    review: enemies.filter(e => sourceLabel(e) === 'review').length,
  }), [enemies, filtered])

  return (
    <section className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 p-4 md:p-8">
      <div className="mx-auto max-w-7xl">
        <div className="flex flex-col gap-3 mb-4">
          <div className="flex items-center justify-between gap-3">
            <div>
              <h2 className="text-xl font-semibold">Bestiary</h2>
              <div className="text-xs text-slate-400 mt-1">
                {counts.shown}/{counts.total} · V {counts.verified} · Rev {counts.review}
              </div>
            </div>
            <div className="flex items-center gap-2">
              <button
                className={`px-3 py-1.5 rounded font-medium transition-colors ${isRefreshing ? 'bg-gray-600 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-500'} text-white`}
                onClick={()=>{ loadData(true).catch(()=>{}); }}
                disabled={isRefreshing}
              >
                {isRefreshing ? 'Refreshing...' : 'Refresh'}
              </button>
              <button
                className={`px-3 py-1.5 rounded font-medium transition-colors ${isRefreshing ? 'bg-gray-600 cursor-not-allowed' : 'bg-green-600 hover:bg-green-500'} text-white`}
                onClick={updateFromDatabase}
                disabled={isRefreshing}
                title="Update from database files"
              >
                {isRefreshing ? 'Updating...' : 'Update'}
              </button>
            </div>
          </div>
          <div className="grid grid-cols-1 md:grid-cols-[1fr_auto_auto] gap-2">
            <input 
              className="px-3 py-1.5 rounded bg-slate-800 border border-slate-600 text-white placeholder-slate-400"
              placeholder="Search name, faction, weapon, trait..."
              value={q} 
              onChange={e=>setQ(e.target.value)} 
            />
            <select className="px-3 py-1.5 rounded bg-slate-800 border border-slate-600 text-white" value={tierFilter} onChange={e=>setTierFilter(e.target.value)}>
              <option value="all">All tiers</option>
              <option value="troops">Troops</option>
              <option value="elite">Elite</option>
              <option value="master">Master</option>
            </select>
            <select className="px-3 py-1.5 rounded bg-slate-800 border border-slate-600 text-white" value={sourceFilter} onChange={e=>setSourceFilter(e.target.value)}>
              <option value="all">All sources</option>
              <option value="verified">Verified</option>
              <option value="review">Review</option>
              <option value="ocr">OCR</option>
            </select>
          </div>
        </div>

        {dbDown && Date.now() < (dismissUntil || 0) ? null : dbDown ? (
          <div className="mb-4 p-3 rounded bg-yellow-800 text-yellow-300 flex items-center justify-between" role="status" aria-live="polite">
            <div>Database unreachable — showing cached data and retrying in background.</div>
            <div className="ml-3">
              <button
                className="text-sm underline"
                aria-label="Dismiss database warning for 5 minutes"
                onClick={()=>{ try{ const until = Date.now() + 5*60*1000; localStorage.setItem(WARNING_DISMISS_KEY, String(until)); setDismissUntil(until) }catch(e){} }}
              >Dismiss</button>
            </div>
          </div>
        ) : null}

        <div className="grid grid-cols-1 gap-2">
          {filtered.map((en, idx) => {
            const d = normalizeEntry(en)
            const key = d.name + idx
            const isOpen = !!expanded[key]
            const tier = tierOf(d.wounds)
            const label = sourceLabel(d)
            return (
            <div key={key} className={`rounded-md bg-slate-800 border ${tier.ring} overflow-hidden`}>
              <div className={`h-1 w-full ${tier.bar}`} />
              <button
                type="button"
                className="w-full text-left p-2 hover:bg-slate-700/50 transition-colors"
                onClick={() => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))}
              >
                <div className="grid grid-cols-1 lg:grid-cols-[minmax(180px,0.9fr)_minmax(260px,1.1fr)_minmax(420px,1.3fr)_auto] gap-2 items-start">
                  <div className="min-w-0">
                    <div className="flex items-center gap-2 flex-wrap">
                      <h3 className="text-sm font-bold text-white leading-tight break-words">{d.name}</h3>
                      <span className="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded-full bg-slate-700 text-slate-300 border border-slate-600">{tierLabel(d)}</span>
                      <span className={`text-[10px] uppercase px-2 py-0.5 rounded-full border ${label === 'verified' ? 'bg-emerald-900/40 text-emerald-300 border-emerald-700/50' : 'bg-amber-900/50 text-amber-300 border-amber-700/50'}`}>{label}</span>
                    </div>
                    <div className="text-xs text-slate-400 mt-1 whitespace-normal break-words">
                      {[d.horde ? 'Horde' : firstValue(d.type, 18), firstValue(d.affiliation, 18), d.book && `${bookAbbr(d.book)}${d.page ? ` p.${d.page}` : ''}`].filter(Boolean).join(' · ')}
                    </div>
                  </div>
                  <StatStrip profile={d.profile} />
                  <div className="grid grid-cols-1 gap-1.5 text-sm">
                    <div className="flex items-center flex-wrap gap-1.5">
                      <QuickBox label={d.horde ? 'Mag' : 'W'} value={d.horde ? (d.magnitude ?? d.wounds) : d.wounds} tone="wound" />
                      <QuickBox label="TB" value={d.toughnessBonus} tone="tough" />
                      <QuickBox label="Move" value={firstValue(d.movement, 12) || '—'} tone="move" title={d.movement} />
                    </div>
                    <div className="grid grid-cols-1 md:grid-cols-2 gap-1.5 items-stretch">
                      <ArmourBlock armour={d.armourLines || d.armour} title={d.armour} />
                      <DamageBlock weapons={d.weapons} profile={d.profile} />
                    </div>
                  </div>
                  <span className="text-slate-400 text-xs justify-self-start lg:justify-self-end">{isOpen ? 'Less' : 'More'}</span>
                </div>
              </button>
              {isOpen && (
                <div className="border-t border-slate-700 p-2">
                  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-1.5">
                    <FieldBlock label="Skill" value={d.skills} className={BOX_TONES.skill} />
                    <FieldBlock label="Tal" value={d.talents} className={BOX_TONES.talent} />
                    <FieldBlock label="Trait" value={d.traits} className={BOX_TONES.trait} />
                    <FieldBlock label="Gear" value={d.gear} className={BOX_TONES.gear} />
                    <FieldBlock label="Src" value={[bookAbbr(d.book), d.page ? `p.${d.page}` : '', d.pdf].filter(Boolean).join(' · ')} className={BOX_TONES.source} />
                  </div>
                </div>
              )}
            </div>
            )
          })}
        </div>
      </div>
    </section>
  )
}
