/* Character Timeline — every character in introduction order, Book 1 → current mapping.
   Computed live from window.DCC data: intro point, lifeline (timeline + mentions), deaths.
   Scales to all seven books: every book gets an EQUAL-width column so the book-to-book
   spacing reads evenly, and chapters are positioned by ordinal rank (so a Prologue or a
   99-numbered Epilogue sits flush at the column edge). A density filter and collapsible
   lanes keep the vertical sprawl manageable as the cast grows. */
(function(){
const { useState, useEffect, useRef, useMemo, useLayoutEffect } = React;
const D = window.DCC;

/* ---------- lanes ---------- */
const CF_LANES = [
  {key:'party',    name:"The Royal Court of Princess Donut", note:'the party',                color:'#ffb13d'},
  {key:'crawlers', name:'Crawlers & Earthlings',             note:'contestants & civilians',  color:'#ff52cf'},
  {key:'dungeon',  name:'Dungeon Denizens, Guides & NPCs',   note:'inside the World Dungeon', color:'#49d6ff'},
  {key:'galaxy',   name:'The Show, Sponsors & the Galaxy',   note:'offworld',                 color:'#b98bff'},
];
const CF_LANE_OF = {};
[['party','carl donut mongo mordecai katia-grim'],
 ['crawlers','bea mrs-parsons rebecca-w frank-q lucia-mar maggie-my brandon-an chris-andrews yolanda-martinez imani-c agatha eldercare-crawlers le-mouvement elle-mcgibbons jack hekla brynhilds-daughters li-jun yvette daniel-bautista dnadia prepotente miriam-dom florin ifechi quan-ch eva-sigrid'],
 ['dungeon','tally rory-lorelai mistress-tiatha grull sebastian over-city-npcs signet grimaldi apollon fitz quint clarabelle pustule vicente city-elf apito featherfall gumgum manasa miss-quill burgundy limp-richard wendita vernon pierre-porter chaco bomo-sledge astrid gore-gore levi-7th dismember fire-brandy widget madison growler-gary damien'],
 ['galaxy','system-ai the-syndicate borant squim lexis odette titan-conglomerate zev the-bloom maestro mexx-55 admin-mukta skull-clan prince-stalwart skull-empire king-rust valtay sensation-ent ripper-wonton tucker hekla-panel loita'],
].forEach(([lane, ids])=> ids.split(' ').forEach(id=>{ CF_LANE_OF[id]=lane; }));

function laneOf(e){
  if(CF_LANE_OF[e.id]) return CF_LANE_OF[e.id];
  const s = ((e.sub||'')+' '+(e.name||'')).toLowerCase();
  if(/crawler|faction/.test(s)) return 'crawlers';
  if(/show|host|producer|syndicate|borant|admin|agent|panel|corp|empire|sponsor|pundit/.test(s)) return 'galaxy';
  return 'dungeon';
}

/* ---------- per-character derivation ---------- */
const DIE_RE = /\bdies\b|\bkilled\b|\bslain\b|\bdeceased\b|\bher death\b|\bhis death\b|\bexecuted\b/i;
function pkey(p){ return p.b*1000 + p.c; }

function deriveChar(e, mappedSet){
  const intro = {b:e.book, c:e.chapter};
  /* BEATS = first appearance + curated timeline[] nodes ONLY. mentions[] is the per-chapter
     occurrence log (entry-page "Major events") and is NOT plotted — see BeatMapping.md §1. */
  const pts = [intro];
  (e.timeline||[]).forEach(t=>{ if(mappedSet.has(t.book)) pts.push({b:t.book, c:t.ch, label:t.label||''}); });
  /* death: a death-worded label on any beat OR mention, else stats Status. mentions are
     scanned for the death only — they are not added as beats. */
  let death = null;
  const deathScan = pts.concat((e.mentions||[])
    .filter(m=>mappedSet.has(m.book||e.book))
    .map(m=>({b:m.book||e.book, c:m.ch, label:m.note||''})));
  for(const p of deathScan){ if(p.label && DIE_RE.test(p.label)){ if(!death || pkey(p)<pkey(death)) death = {b:p.b, c:p.c}; } }
  if(!death){
    const st = (e.stats||[]).find(s=>/^status$/i.test(s[0]) && /deceased|dies|killed/i.test(String(s[1])));
    if(st){
      const mm = String(st[1]).match(/(?:B(\d+)\s*[·,.\s]*)?C(\d+)/i);
      death = {b: mm && mm[1] ? +mm[1] : e.book, c: mm ? +mm[2] : e.chapter};
    }
  }
  /* dedupe beats */
  const seen = {}; const beats = [];
  pts.forEach(p=>{ const k=pkey(p); if(!seen[k]){ seen[k]=1; beats.push(p); } });
  beats.sort((a,b)=>pkey(a)-pkey(b));
  const books = new Set(beats.map(p=>p.b));
  /* "recurring" = a character the index actively tracks across the crawl, not a one-beat walk-on */
  const recurring = books.size>=2 || beats.length>=4 || !!death;
  return { e, lane:laneOf(e), intro, beats, death, last: beats[beats.length-1], spanBooks:books.size, recurring };
}

/* ---------- the view ---------- */
function CharFlow({go}){
  const mappedBooks = D.BOOKS.filter(b=>b.status==='live').map(b=>b.n);
  const maxBook = Math.max.apply(null, mappedBooks);
  const [horizon, setHorizon] = useState(()=>{
    const v = parseInt(localStorage.getItem('dcc-cf-horizon'),10);
    return (v && mappedBooks.includes(v)) ? v : maxBook;
  });
  useEffect(()=>{ localStorage.setItem('dcc-cf-horizon', String(horizon)); },[horizon]);
  const [density, setDensity] = useState(()=> localStorage.getItem('dcc-cf-density') || 'recurring');
  useEffect(()=>{ localStorage.setItem('dcc-cf-density', density); },[density]);
  const [collapsed, setCollapsed] = useState({});
  const [hover, setHover] = useState(null); // {id, x, y}
  const boxRef = useRef(null);
  const axisRef = useRef(null);
  const canvasRef = useRef(null);

  /* available width drives fit-vs-scroll; measured live so it stays correct on resize */
  const [vw, setVw] = useState(1100);
  useLayoutEffect(()=>{
    function measure(){ if(boxRef.current) setVw(boxRef.current.clientWidth); }
    measure();
    const ro = ('ResizeObserver' in window) ? new ResizeObserver(measure) : null;
    if(ro && boxRef.current) ro.observe(boxRef.current);
    window.addEventListener('resize', measure);
    return ()=>{ if(ro) ro.disconnect(); window.removeEventListener('resize', measure); };
  },[]);

  const model = useMemo(()=>{
    const mappedSet = new Set(mappedBooks);
    const cast = D.allEntries()
      .filter(en=> en.type==='character' || en.id==='mongo')
      .filter(en=> mappedSet.has(en.book))
      .map(en=> deriveChar(en, mappedSet));

    /* x geometry — CONSISTENT SPACING: every book is allotted the SAME column width, so the
       eye reads even book-to-book gaps regardless of chapter count. Fill the frame when the
       whole run fits; otherwise fall back to a readable minimum per book and scroll. */
    const PAD = 4, MIN_BOOK_W = 156, INSET = 0.05;
    const avail = Math.max(560, vw);
    const books = mappedBooks.slice().sort((a,b)=>a-b).map(n=>{
      const meta = D.BOOKS.find(b=>b.n===n);
      const nums = D.chaptersFor(n).map(c=>c.num).sort((a,b)=>a-b);
      return {n, title:meta.title, nums, chCount:nums.length, minCh:nums[0], maxCh:nums[nums.length-1]};
    });
    const nB = books.length || 1;
    const BOOK_W = Math.max(MIN_BOOK_W, (avail - PAD*2)/nB);
    const W = Math.max(avail, nB*BOOK_W + PAD*2);
    let off = PAD;
    books.forEach(b=>{ b.w = BOOK_W; b.x0 = off; off += BOOK_W; });
    const bx = {}; books.forEach(b=>{ bx[b.n]=b; });
    /* position a chapter by its ORDINAL rank among the book's real chapters: a Prologue
       (ch 0) sits flush left and an Epilogue (numbered 99) flush right, no phantom gap,
       and every chapter is evenly spaced regardless of its raw number. */
    function frac(b,c){
      const a=bx[b].nums, L=a.length;
      if(L<=1) return 0.5;
      if(c<=a[0]) return 0; if(c>=a[L-1]) return 1;
      let i=0; while(i<L-1 && a[i+1]<=c) i++;
      return (i + (c-a[i])/(a[i+1]-a[i])) / (L-1);
    }
    const X = (b,c)=> bx[b].x0 + (INSET + frac(b,c)*(1-2*INSET)) * bx[b].w;
    return {cast, books, bx, X, W, scrollable: W > avail + 1};
  },[mappedBooks.join(','), vw]);

  /* horizon clipping + density filter */
  const horizonEnd = pkey({b:horizon, c:999});
  const inHorizon = model.cast.filter(c=> c.intro.b <= horizon);
  const recurCount = inHorizon.filter(c=>c.recurring).length;
  const visible = inHorizon.filter(c=> density==='all' ? true : c.recurring);
  const lanesAll = CF_LANES.map(L=>{
    const rows = visible.filter(c=>c.lane===L.key)
      .sort((a,b)=> pkey(a.intro)-pkey(b.intro) || a.e.name.localeCompare(b.e.name));
    return {...L, rows, collapsed:!!collapsed[L.key]};
  }).filter(L=>L.rows.length);

  /* y geometry */
  const ROW = 30, LANE_H = 64, COLLAPSED_H = 30, GAP = 18, TOP = 14;
  let y = TOP;
  const lanes = lanesAll.map(L=>{
    const yy = y;
    y += L.collapsed ? (COLLAPSED_H + GAP) : (LANE_H + L.rows.length*ROW + GAP);
    return {...L, y:yy};
  });
  const H = y + 8;
  const {books, X, W} = model;
  const deaths = visible.filter(c=> c.death && pkey(c.death) <= horizonEnd).length;

  /* keep the sticky axis aligned with the horizontally-scrolled chart */
  function syncScroll(){
    if(canvasRef.current && axisRef.current)
      canvasRef.current.style.transform = `translateX(${-axisRef.current.scrollLeft}px)`;
  }
  useEffect(()=>{ syncScroll(); },[model.W, horizon, density]);
  function onWheel(ev){
    if(!model.scrollable || !axisRef.current) return;
    if(Math.abs(ev.deltaX) > Math.abs(ev.deltaY)){ axisRef.current.scrollLeft += ev.deltaX; syncScroll(); }
  }

  function onMove(ev){
    if(!boxRef.current) return;
    const r = boxRef.current.getBoundingClientRect();
    const id = ev.target.closest && ev.target.closest('[data-cf]') ? ev.target.closest('[data-cf]').getAttribute('data-cf') : null;
    if(id) setHover({id, x: ev.clientX - r.left, y: ev.clientY - r.top});
    else setHover(null);
  }
  const hc = hover ? visible.find(c=>c.e.id===hover.id) : null;
  const toggleLane = k => setCollapsed(s=>({...s, [k]:!s[k]}));

  return (
    <div className="view" data-screen-label="Character Timeline"><div className="wrap">
      <div className="crumbs">
        <a onClick={()=>go({view:'index'})}>Index</a><span className="sep">/</span>
        <span style={{color:'var(--ink)'}}>Character Timeline</span>
      </div>
      <div className="sec-top">
        <h2>Character <span className="am">Timeline</span></h2>
        <p className="note">{visible.length} of {inHorizon.length} cast members in introduction order, Book 1 → Book {horizon} — each line is a
          character's run on the feed, from first appearance to their last logged beat. {deaths} confirmed death{deaths===1?'':'s'} shown.
          {model.scrollable ? ' Scroll sideways to follow the crawl deeper.' : ''}</p>
      </div>

      <div className="cf-bar">
        <div className="cf-legend">
          <span><i className="cf-li dot"></i> first appearance</span>
          <span><i className="cf-li beat"></i> arc beat</span>
          <span><i className="cf-li line"></i> active on the feed</span>
          <span><b className="cf-skull">†</b> death</span>
        </div>
        <div className="cf-controls">
          <div className="cf-seg" role="group" aria-label="Cast density">
            <button className={'cf-segbtn'+(density==='recurring'?' on':'')} onClick={()=>setDensity('recurring')}>Recurring · {recurCount}</button>
            <button className={'cf-segbtn'+(density==='all'?' on':'')} onClick={()=>setDensity('all')}>All · {inHorizon.length}</button>
          </div>
          <div className="cf-horizon">
            <span className="cf-hl">HORIZON</span>
            {books.map(b=>(
              <button key={b.n} className={'cf-hbtn'+(horizon===b.n?' on':'')} onClick={()=>setHorizon(b.n)}>
                {b.n===1 ? 'B1' : `B1–${b.n}`}
              </button>
            ))}
          </div>
        </div>
      </div>

      <div className="cf-axis" ref={axisRef} onScroll={syncScroll}>
        <div className="cf-axis-inner" style={{width:W}}>
          {books.filter(b=>b.n<=horizon).map(b=>(
            <div key={b.n} className="cf-axis-book" style={{width:b.w}}>
              <b>BOOK {b.n}</b><span>{b.title} · {b.chCount} ch</span>
            </div>
          ))}
          {horizon < books.length && <div className="cf-axis-sealed" style={{flex:1,minWidth:120}}>■ SEALED — raise horizon</div>}
        </div>
      </div>

      <div className="cf-box" ref={boxRef} onMouseMove={onMove} onMouseLeave={()=>setHover(null)} onWheel={onWheel}>
        <div className="cf-canvas" ref={canvasRef} style={{width:W}}>
        <svg className="cf-svg" viewBox={`0 0 ${W} ${H}`} width={W} height={H} style={{display:'block'}}>
          {/* book boundary gridlines + chapter ticks */}
          {books.map(b=>(
            <g key={b.n} opacity={b.n<=horizon?1:0}>
              <line x1={b.x0} y1={0} x2={b.x0} y2={H} stroke="var(--line-2)" strokeWidth="1"/>
              {b.nums.filter(c=>c>0 && c%10===0 && c<b.maxCh).map(c=>(
                <line key={c} x1={X(b.n,c)} y1={0} x2={X(b.n,c)} y2={H} stroke="var(--line)" strokeWidth="1" strokeDasharray="2 6"/>
              ))}
            </g>
          ))}
          {horizon < books.length && (
            <rect x={model.bx[horizon].x0 + model.bx[horizon].w} y={0}
              width={W - (model.bx[horizon].x0 + model.bx[horizon].w)} height={H} className="cf-seal"/>
          )}

          {lanes.map(L=>(
            <g key={L.key}>
              <g className="cf-lane-head" onClick={()=>toggleLane(L.key)} style={{cursor:'pointer'}}>
                <rect x={0} y={L.y} width={W} height={L.collapsed?COLLAPSED_H:46} fill="transparent"/>
                <text x={6} y={L.y+20} className="cf-lane-name" fill={L.color}>
                  <tspan className="cf-caret">{L.collapsed?'▸':'▾'}</tspan> {L.name.toUpperCase()}
                </text>
                {!L.collapsed && <text x={6} y={L.y+38} className="cf-lane-note">{L.note} · {L.rows.length}</text>}
                {L.collapsed && <text x={W-6} y={L.y+20} textAnchor="end" className="cf-lane-note">{L.rows.length} hidden — click to expand</text>}
              </g>
              {!L.collapsed && <line x1={4} y1={L.y+50} x2={W-4} y2={L.y+50} stroke={L.color} strokeOpacity=".35" strokeWidth="1"/>}
              {!L.collapsed && L.rows.map((c,i)=>{
                const cy = L.y + LANE_H + i*ROW + ROW - 9;
                const x1 = X(c.intro.b, c.intro.c);
                const lastVis = pkey(c.last) > horizonEnd ? {b:horizon, c:model.bx[horizon].maxCh} : c.last;
                const x2 = Math.max(X(lastVis.b, lastVis.c), x1);
                const dead = c.death && pkey(c.death) <= horizonEnd;
                const labelLeft = x1 > W*0.84;
                const dim = hover && hover.id!==c.e.id;
                return (
                  <g key={c.e.id} data-cf={c.e.id} className="cf-row" opacity={dim?0.32:1}
                     onClick={()=>go({view:'entry', id:c.e.id})}>
                    <rect x={0} y={cy-ROW+9} width={W} height={ROW} fill="transparent"/>
                    {x2>x1+1 && <line x1={x1} y1={cy} x2={x2} y2={cy} stroke={L.color} strokeWidth="2"
                      strokeOpacity=".75" strokeDasharray={pkey(c.last)>horizonEnd ? '3 4' : 'none'}/>}
                    {c.beats.filter(p=> pkey(p)<=horizonEnd && pkey(p)!==pkey(c.intro)).map((p,j)=>(
                      <circle key={j} cx={X(p.b,p.c)} cy={cy} r="2.4" fill={L.color} fillOpacity=".9"/>
                    ))}
                    <circle cx={x1} cy={cy} r="4" fill={L.color} stroke="var(--bg-0)" strokeWidth="1.5"/>
                    {dead && <text x={X(c.death.b,c.death.c)} y={cy+5} textAnchor="middle" className="cf-cross">†</text>}
                    <text x={labelLeft ? x1-9 : x1+9} y={cy-7}
                      textAnchor={labelLeft?'end':'start'} className="cf-name">{c.e.name}</text>
                  </g>
                );
              })}
            </g>
          ))}
        </svg>
        </div>

        {hc && (
          <div className="cf-tip" style={{
            left: Math.min(Math.max(hover.x+14, 8), (boxRef.current?boxRef.current.clientWidth:800)-260),
            top: hover.y+16 }}>
            <div className="cf-tip-name">{hc.e.name}</div>
            <div className="cf-tip-sub">{hc.e.sub}</div>
            <div className="cf-tip-meta">
              <span>First seen <b>B{hc.intro.b}·C{hc.intro.c}</b></span>
              {pkey(hc.last)>pkey(hc.intro) && <span>Last logged <b>B{Math.min(hc.last.b,horizon)}·C{pkey(hc.last)>horizonEnd?'??':hc.last.c}</b></span>}
              {hc.death && pkey(hc.death)<=horizonEnd && <span className="cf-skull">† B{hc.death.b}·C{hc.death.c}</span>}
            </div>
            <div className="cf-tip-cta">click to open entry ▸</div>
          </div>
        )}
      </div>

      <p className="cf-foot">Each dot is a curated <b>arc beat</b> from the character's appearance map — first
        appearance, turning points, and death — not every cameo; the full per-chapter occurrence log lives on
        each character's entry. Chapters between beats are interpolated, and a dashed tail means the character
        is still active past your spoiler horizon. Click a lane header to collapse it; switch to <b>All</b> to
        surface one-scene walk-ons; lower the horizon to hide later-book intros and fates. Every book gets an
        equal-width column, so book-to-book spacing reads evenly as the crawl deepens.</p>
    </div></div>
  );
}

window.CharFlow = CharFlow;
})();
