#!/usr/bin/env python3
"""Build factual Classic reference pages from the retained, attributed SQLite snapshot.
No Wowhead HTML, descriptions, guides, comments or artwork are copied.
"""
import argparse, collections, hashlib, html, json, re, shutil, sqlite3, subprocess
from world_library_extras import extract
from pathlib import Path

ROOT=Path(__file__).resolve().parents[1]
DATE='2026-10-18'
VERSION='20261015-discussions-v3'
SOURCE='CMaNGOS Classic-DB snapshot'
STATUS='Classic baseline — awaiting Forever verification'
def esc(v): return html.escape(str(v if v is not None else ''))
def slug(v): return re.sub('[^a-z0-9]+','-',v.lower()).strip('-')
def save(path,data):
 p=ROOT/path; p.parent.mkdir(parents=True,exist_ok=True); p.write_text(data,encoding='utf-8')
def dump(path,data): save(path,json.dumps(data,ensure_ascii=False,separators=(',',':')))
def clean(name): return bool(name) and not re.search(r'\b(deprecated|unused|placeholder|invisible|trigger|visual only)\b|^zz|^test quest$|\btestquest\b|\[ph\]',name,re.I)
def fact(label,value): return '' if value is None or value=='' else f'<div><dt>{esc(label)}</dt><dd>{esc(value)}</dd></div>'
def link(record): return f'<a href="{record["recordUrl"]}">{esc(record["name"])}</a>'
def links(records,limit=80):
 records=list({r['recordUrl']:r for r in records}.values())
 return '<ul class="db-relations">'+''.join('<li>'+link(r)+'</li>' for r in records[:limit])+'</ul>'+ (f'<p>{len(records):,} relations recorded; showing the first {limit}.</p>' if len(records)>limit else '') if records else '<p>No relationship recorded in this snapshot.</p>'
def section(title,body): return f'<section class="db-section"><h2>{esc(title)}</h2>{body}</section>'

parser=argparse.ArgumentParser(); parser.add_argument('--database',required=True); args=parser.parse_args()
c=sqlite3.connect(f'file:{Path(args.database).resolve()}?mode=ro',uri=True); c.row_factory=sqlite3.Row
rawq={r['entry']:dict(r) for r in c.execute('SELECT * FROM quest_template')}
rawi={r['entry']:dict(r) for r in c.execute('SELECT * FROM item_template')}
rawn={r['Entry']:dict(r) for r in c.execute('SELECT * FROM creature_template')}
objects={r['entry']:r['name'] for r in c.execute('SELECT entry,name FROM gameobject_template')}
spawned={r[0] for r in c.execute('SELECT DISTINCT id FROM creature')}
existingq=json.loads((ROOT/'quests/quests.json').read_text())['quests']; existingi=json.loads((ROOT/'items/items.json').read_text())['items']
previous_manifest=ROOT/'output/world-library/manifest.json'
previous_paths=set(json.loads(previous_manifest.read_text())['paths']) if previous_manifest.exists() else set()
qby={q['numericId']:q for q in existingq if q.get('numericId')}; iby={int(i['id']):i for i in existingi}
for retained in json.loads((ROOT/'data/kalimdor-reference.json').read_text())['quests']:
 if retained['numericId'] not in qby:
  qby[retained['numericId']]={**retained,'zoneUrl':f'/zones/kalimdor/{retained["zoneSlug"]}/','recordUrl':f'/quests/{retained["id"]}/','journey':retained.get('dungeon') or retained['zone']}
classes={1:'Warrior',2:'Paladin',4:'Hunter',8:'Rogue',16:'Priest',64:'Shaman',128:'Mage',256:'Warlock',1024:'Druid'}
skills={164:'Blacksmithing',165:'Leatherworking',171:'Alchemy',185:'Cooking',197:'Tailoring',202:'Engineering',333:'Enchanting',129:'First Aid',356:'Fishing',186:'Mining',182:'Herbalism',393:'Skinning'}
quality={0:'Poor',1:'Common',2:'Uncommon',3:'Rare',4:'Epic',5:'Legendary',6:'Artifact'}
slots={1:'Head',2:'Neck',3:'Shoulder',4:'Shirt',5:'Chest',6:'Waist',7:'Legs',8:'Feet',9:'Wrist',10:'Hands',11:'Finger',12:'Trinket',13:'One-Hand',14:'Shield',15:'Ranged',16:'Back',17:'Two-Hand',18:'Bag',19:'Tabard',20:'Chest',21:'Main Hand',22:'Off Hand',23:'Held In Off-hand',24:'Ammo',25:'Thrown',26:'Ranged',28:'Relic'}
itemclasses={0:'Consumable',1:'Bag',2:'Weapon',4:'Armor',5:'Reagent',6:'Projectile',7:'Trade goods',9:'Recipe',11:'Quiver',12:'Quest item',13:'Key',15:'Miscellaneous'}
stats={0:'Mana',1:'Health',3:'Agility',4:'Strength',5:'Intellect',6:'Spirit',7:'Stamina'}
zones={1:'Dun Morogh',3:'Badlands',4:'Blasted Lands',8:'Swamp of Sorrows',10:'Duskwood',11:'Wetlands',12:'Elwynn Forest',14:'Durotar',15:'Dustwallow Marsh',16:'Azshara',17:'The Barrens',28:'Western Plaguelands',33:'Stranglethorn Vale',36:'Alterac Mountains',38:'Loch Modan',40:'Westfall',41:'Deadwind Pass',44:'Redridge Mountains',45:'Arathi Highlands',46:'Burning Steppes',47:'The Hinterlands',51:'Searing Gorge',85:'Tirisfal Glades',130:'Silverpine Forest',139:'Eastern Plaguelands',141:'Teldrassil',148:'Darkshore',215:'Mulgore',267:'Hillsbrad Foothills',331:'Ashenvale',357:'Feralas',361:'Felwood',400:'Thousand Needles',405:'Desolace',406:'Stonetalon Mountains',440:'Tanaris',490:'Un’Goro Crater',493:'Moonglade',618:'Winterspring',1377:'Silithus',1497:'Undercity',1519:'Stormwind City',1537:'Ironforge',1637:'Orgrimmar',1638:'Thunder Bluff',1657:'Darnassus'}
dungeons={209:'Zul’Farrak',491:'Razorfen Kraul',718:'Wailing Caverns',719:'Blackfathom Deeps',722:'Razorfen Downs',717:'The Stockade',1581:'The Deadmines',1583:'Blackrock Spire',1584:'Blackrock Depths',2017:'Stratholme',2057:'Scholomance',2100:'Maraudon',2557:'Dire Maul',133:'Shadowfang Keep',796:'Scarlet Monastery',721:'Gnomeregan',1477:'The Temple of Atal’Hakkar',3428:'Temple of Ahn’Qiraj',3429:'Ruins of Ahn’Qiraj',3456:'Naxxramas',2717:'Molten Core',2677:'Blackwing Lair',1977:'Zul’Gurub',2159:'Onyxia’s Lair'}
zones.update(dungeons)
subzones={9:12,24:12,35:33,25:267,154:85,132:1,363:14,188:141,220:215,221:215}
zone_urls={q['zone']:q['zoneUrl'] for q in existingq if q.get('zoneUrl')}
for path in (ROOT/'zones').glob('*/index.html'):
 title=re.search(r'<h1>(.*?)</h1>',path.read_text(),re.S)
 if title: zone_urls[html.unescape(re.sub('<[^>]+>','',title[1]))]='/zones/'+path.parent.name+'/'
starts=collections.defaultdict(list); ends=collections.defaultdict(list)
for table,kind,names,target in [('creature_questrelation','NPC',{i:r['Name'] for i,r in rawn.items()},starts),('creature_involvedrelation','NPC',{i:r['Name'] for i,r in rawn.items()},ends),('gameobject_questrelation','Object',objects,starts),('gameobject_involvedrelation','Object',objects,ends)]:
 for r in c.execute('SELECT id,quest FROM '+table): target[r['quest']].append({'id':r['id'],'name':names.get(r['id'],str(r['id'])),'type':kind})
for i in rawi.values():
 if i['startquest']: starts[i['startquest']].append({'id':i['entry'],'name':i['name'],'type':'Item'})

quests=[]
for qid,q in rawq.items():
 if not clean(q['Title']) or q['Title'] in ('Welcome!','Onu is meditating') or q['MinLevel']>60: continue
 if qid in qby:
  record=dict(qby[qid]); record['endSources']=ends[qid]; quests.append(record); continue
 category=subzones.get(q['ZoneOrSort'],q['ZoneOrSort']); zone=zones.get(category,'Class quests' if q['RequiredClasses'] else 'Special & event quests' if category<0 else 'Other assignments')
 objectives=[]; rewards=[]
 for n in range(1,5):
  iid=q[f'ReqItemId{n}']; tid=q[f'ReqCreatureOrGOId{n}']
  if iid: objectives.append({'type':'Item','id':iid,'name':rawi.get(iid,{}).get('name',str(iid)),'count':q[f'ReqItemCount{n}']})
  if tid: objectives.append({'type':'Object' if tid<0 else 'Action' if q[f'ReqSpellCast{n}'] else 'Creature','id':abs(tid),'name':objects.get(-tid,str(-tid)) if tid<0 else rawn.get(tid,{}).get('Name',str(tid)),'count':q[f'ReqCreatureOrGOCount{n}']})
 for prefix,count,choice in [('RewItem',4,False),('RewChoiceItem',6,True)]:
  for n in range(1,count+1):
   iid=q[f'{prefix}Id{n}']
   if iid: rewards.append({'id':iid,'name':rawi.get(iid,{}).get('name',str(iid)),'count':q[f'{prefix}Count{n}'],'choice':choice})
 historical=not starts[qid] and not q['PrevQuestId'] and not q['NextQuestInChain']
 typ='Historical' if historical else 'Raid' if q['QuestFlags']&64 else 'Dungeon' if category in dungeons else 'Class' if q['RequiredClasses'] else 'Group' if q['SuggestedPlayers']>1 or q['Type']==1 else 'Event' if category<0 and not q['RequiredClasses'] else 'Chain' if q['PrevQuestId'] or q['NextQuestId'] or q['NextQuestInChain'] else 'Normal'
 obs=[o for o in objectives if clean(o['name'])]
 summary=('Objectives recorded: '+ '; '.join(f'{o["name"]} × {o["count"]}' for o in obs)+'.') if obs else ('Report to '+', '.join(dict.fromkeys(r['name'] for r in ends[qid]))+'.') if ends[qid] else 'Follow the current quest log for this assignment; no structured targets are recorded in the reference snapshot.'
 races=q['RequiredRaces']; a=bool(races&77); h=bool(races&178)
 minimum=max(1,q['MinLevel']); upper=None if q['MaxLevel'] in (0,255) else q['MaxLevel']; band=min(60,max(minimum,q['QuestLevel'])+3,upper or 60)
 record={'id':slug(q['Title'])+'-'+str(qid),'numericId':qid,'name':q['Title'],'zone':zone,'zoneUrl':zone_urls.get(zone,'/library/?tab=quests&zone='+__import__('urllib.parse',fromlist=['quote']).quote(zone)),'faction':'Alliance' if a and not h else 'Horde' if h and not a else 'Both','type':typ,'minLevel':minimum,'maxLevel':max(minimum,band),'questLevel':q['QuestLevel'],'requiredMaxLevel':upper,'classRestrictions':[v for k,v in classes.items() if q['RequiredClasses']&k],'starts':', '.join(dict.fromkeys(r['name'] for r in starts[qid])) or 'No active start relation recorded','startSources':starts[qid],'endSources':ends[qid],'ends':', '.join(dict.fromkeys(r['name'] for r in ends[qid])),'summary':summary,'objectives':objectives,'rewards':rewards,'requiredItemIds':[o['id'] for o in objectives if o['type']=='Item'],'sourceItemIds':[q['SrcItemId']] if q['SrcItemId'] else [],'previousQuestId':q['PrevQuestId'],'nextQuestId':q['NextQuestId'],'nextQuestInChain':q['NextQuestInChain'],'suggestedPlayers':q['SuggestedPlayers'],'repeatable':bool(q['SpecialFlags']&1),'requiredSkillId':q['RequiredSkill'],'requiredSkillValue':q['RequiredSkillValue'],'sourceUrl':f'https://www.wowhead.com/classic/quest={qid}','availability':STATUS,'journey':dungeons.get(category,zone),'verificationStatus':'classic-baseline'}
 record['recordUrl']='/quests/'+record['id']+'/'
 quests.append(record)
# Keep editorial-only routes and all existing numeric IDs / URLs.
quests += [q for q in existingq if not q.get('numericId')]
qby={q['numericId']:q for q in quests if q.get('numericId')}
itemquests=collections.defaultdict(list); npcquests=collections.defaultdict(lambda:collections.defaultdict(list))
for q in quests:
 for reward in q.get('rewards',[]): itemquests[reward.get('id',reward.get('itemId'))].append((q,'Reward choice' if reward.get('choice') else 'Quest reward'))
 for iid in q.get('requiredItemIds',[]): itemquests[iid].append((q,'Quest objective'))
 for iid in q.get('sourceItemIds',[]): itemquests[iid].append((q,'Quest tool'))
 for group,key in [('Starts', 'startSources'),('Ends','endSources')]:
  for r in q.get(key,[]):
   if r['type']=='NPC': npcquests[r['id']][group].append(q)
 for r in q.get('objectives',[]):
  if r['type'] in ('Creature','Action'): npcquests[r['id']]['Objective'].append(q)

vendors=collections.defaultdict(set); drops=collections.defaultdict(set)
for r in c.execute('SELECT entry,item FROM npc_vendor'): vendors[r['item']].add(r['entry'])
tables={r[0] for r in c.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if 'npc_vendor_template' in tables:
 templatevendors=collections.defaultdict(set)
 for nid,n in rawn.items():
  if n['VendorTemplateId']: templatevendors[n['VendorTemplateId']].add(nid)
 for r in c.execute('SELECT entry,item FROM npc_vendor_template'): vendors[r['item']].update(templatevendors[r['entry']])
# Only direct loot relations are labelled as such; no inferred percentages.
lootnpcs=collections.defaultdict(set)
for nid,n in rawn.items():
 if n['LootId'] and nid in spawned and clean(n['Name']): lootnpcs[n['LootId']].add(nid)
for r in c.execute('SELECT entry,item FROM creature_loot_template WHERE mincountOrRef>=0 AND item>0'): drops[r['item']].update(lootnpcs[r['entry']])
items=[]
for iid,i in rawi.items():
 if (not clean(i['name']) or re.search(r'\btest\b|^\(OLD\)|\bdebug\b',i['name'],re.I) or i['RequiredLevel']>60) and not iby.get(iid,{}).get('loadouts'): continue
 category=itemclasses.get(i['class'],'Other'); old=iby.get(iid,{})
 related=list({q['id']:q for q,_ in itemquests[iid]}.values()); zoneslist=list(dict.fromkeys(q['zone'] for q in related))
 sourcekinds=list(dict.fromkeys(t for _,t in itemquests[iid]))
 if vendors[iid]:sourcekinds.append('Vendor')
 if drops[iid]:sourcekinds.append('Creature drop')
 statlines=[{'name':stats.get(i[f'stat_type{n}'],f'Stat {i[f"stat_type{n}"]}'),'value':i[f'stat_value{n}']} for n in range(1,11) if i[f'stat_value{n}']]
 record={**old,'id':iid,'numericId':iid,'name':i['name'],'slot':slots.get(i['InventoryType'],category),'category':category,'quality':quality.get(i['Quality'],'Other'),'qualityId':i['Quality'],'requiredLevel':i['RequiredLevel'],'itemLevel':i['ItemLevel'],'classId':i['class'],'subclassId':i['subclass'],'armor':i['armor'],'block':i['block'],'stats':statlines,'damageMin':sum(i[f'dmg_min{n}'] for n in range(1,6)),'damageMax':sum(i[f'dmg_max{n}'] for n in range(1,6)),'speed':i['delay']/1000,'sellPrice':i['SellPrice'],'stackSize':i['stackable'],'requiredSkill':skills.get(i['RequiredSkill'], {1:'Leatherworking',2:'Tailoring',3:'Engineering',4:'Blacksmithing',5:'Cooking',6:'Alchemy',7:'First Aid',8:'Enchanting',9:'Fishing'}.get(i['subclass'],'') if i['class']==9 else ''),'requiredSkillRank':i['RequiredSkillRank'],'binding':{0:'Unbound',1:'Binds when picked up',2:'Binds when equipped',3:'Binds when used',4:'Quest item'}.get(i['bonding'],'Other'),'durability':i['MaxDurability'],'questIds':[q['numericId'] for q in related if q.get('numericId')],'questLinks':[{'name':q['name'],'recordUrl':q['recordUrl']} for q in related],'zones':list(dict.fromkeys(old.get('zones',[])+zoneslist)),'source':old.get('source') if old.get('loadouts') else ' / '.join(sourcekinds) or 'Source not recorded','drop':old.get('drop') or '; '.join(q['name'] for q in related[:4]),'vendorIds':sorted(vendors[iid]),'dropNpcIds':sorted(drops[iid]),'recordUrl':f'/items/{iid}/','sourceUrl':f'https://www.wowhead.com/classic/item={iid}','availability':old.get('availability',STATUS),'verificationStatus':old.get('verificationStatus','classic-baseline')}
 items.append(record)
# Preserve the retained Forever-only editorial shortlist outside the Classic snapshot.
retained_loadout=json.loads((ROOT/'data/paladin-loadout.json').read_text())['items']
present={i['id'] for i in items}
for old in retained_loadout:
 if old['id'] in present: continue
 items.append({**old,'numericId':old['id'],'category':'Editorial shortlist','quality':'Unverified','requiredLevel':None,'itemLevel':None,'classId':None,'stats':[],'vendorIds':[],'dropNpcIds':[],'questIds':[],'zones':[],'loadouts':['protection-paladin-30'],'recordUrl':f'/items/{old["id"]}/','sourceUrl':f'https://www.wowhead.com/forever/item={old["id"]}','availability':'Editorial beta shortlist — verify in the current client','verificationStatus':'classic-baseline','editorialOnly':True})
iby={i['id']:i for i in items}
npcs=[]
for nid,n in rawn.items():
 if not clean(n['Name']) or re.search(r'^test(?:\s|$)',n['Name'],re.I) or (nid not in spawned and nid not in npcquests) or n['MinLevel']>65: continue
 roles=[]
 if n['NpcFlags']&2: roles.append('Quest giver')
 if n['NpcFlags']&128: roles.append('Vendor')
 if n['NpcFlags']&16: roles.append('Trainer')
 record={'id':nid,'name':n['Name'],'subtitle':n['SubName'],'minLevel':n['MinLevel'],'maxLevel':n['MaxLevel'],'rank':{0:'Normal',1:'Elite',2:'Rare elite',3:'Boss',4:'Rare'}.get(n['Rank'],'Other'),'type':{1:'Beast',2:'Dragonkin',3:'Demon',4:'Elemental',5:'Giant',6:'Undead',7:'Humanoid',8:'Critter',9:'Mechanical',10:'Unspecified'}.get(n['CreatureType'],'Other'),'roles':roles,'recordUrl':f'/npcs/{nid}/','sourceUrl':f'https://www.wowhead.com/classic/npc={nid}'}
 npcs.append(record)
nby={n['id']:n for n in npcs}
npcitems=collections.defaultdict(lambda:collections.defaultdict(list))
for i in items:
 i['vendorIds']=[nid for nid in i['vendorIds'] if nid in nby]; i['dropNpcIds']=[nid for nid in i['dropNpcIds'] if nid in nby]
 for nid in i['vendorIds']: npcitems[nid]['Sold items'].append(i)
 for nid in i['dropNpcIds']: npcitems[nid]['Direct loot records'].append(i)

extra_catalogs=extract(c,quests,items,npcs,clean)
world_objects=extra_catalogs['objects'];spells=extra_catalogs['spells']
oby={r['id']:r for r in world_objects};sby={r['id']:r for r in spells}
for item in items:
 item['objectIds']=[r['id'] for r in extra_catalogs['item_objects'][item['id']]]
 item['spellIds']=[r['id'] for r in extra_catalogs['item_spells'][item['id']]]
for npc in npcs:npc['spellIds']=[r['id'] for r in extra_catalogs['npc_spells'][npc['id']]]

base=(ROOT/'items/index.html').read_text(); nav=re.search(r'<header class="site-header[\s\S]*?</header>',base)[0].replace('href="/items/" aria-current="page"','href="/library/"').replace('>Items</a>','>Database</a>'); footer=re.search(r'<footer>[\s\S]*?</footer>',base)[0]
notice='<aside class="db-notice"><strong>Classic reference · Forever verification pending</strong><p>This is a community-maintained original-world snapshot, not confirmed Forever data. Availability, rewards and tuning may differ. <a href="/library/sources/">Sources, coverage and data downloads →</a></p></aside>'
def shell(title,description,body,extra=''):
 return f'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{esc(title)} — WoW Forever Friends</title><meta name="description" content="{esc(description)}"><link rel="icon" href="/assets/brand/tabard-crest-painted-v2.png"><link rel="stylesheet" href="/styles.css?v={VERSION}"><link rel="stylesheet" href="/assets/library/database.css?v={VERSION}"><link rel="stylesheet" href="/assets/library/comments.css?v={VERSION}"></head><body class="database-page"><a class="skip-link" href="#main">Skip to content</a>{nav}<main id="main" class="database-main">{body}</main>{footer}<script src="/app.js?v={VERSION}"></script>{extra}</body></html>'
def discussion(kind,record):
 singular={'items':'item','quests':'quest','npcs':'npc','objects':'object','spells':'spell'}[kind]
 rid=record['id']
 return f'''<!-- library-discussion:start --><section id="comments" class="library-discussion" data-record-discussion data-kind="{singular}" data-record-id="{esc(rid)}"><div class="discussion-heading"><h2>Comments <span data-discussion-count>…</span></h2><label>Sort comments<select data-discussion-sort><option value="newest">Newest first</option><option value="oldest">Oldest first</option><option value="helpful">Most helpful</option></select></label></div><p class="discussion-intro">Share a route tip, source correction or discovery. Mention whether you played Forever or Classic and include your game build when possible.</p><p class="discussion-status" data-discussion-status role="status" aria-live="polite">Loading comments…</p><div data-discussion-list></div><nav class="discussion-pagination" aria-label="Comment pages"><button type="button" data-discussion-prev disabled>Previous comments</button><span data-discussion-page></span><button type="button" data-discussion-next disabled>Next comments</button></nav><form class="discussion-form" data-discussion-form><div class="discussion-replying" data-discussion-replying hidden><span></span><button type="button" data-discussion-cancel-reply>Cancel reply</button></div><label>Your comment<textarea name="body" aria-label="Your comment" rows="4" required minlength="2" maxlength="1200" placeholder="What would help another adventurer? Write in your own words."></textarea></label><small data-discussion-remaining>0/1,200 characters</small><p class="discussion-login" data-discussion-login><a href="/community/#login">Sign in free</a> or <a href="/community/#register">create an account</a> to join the discussion.</p><button type="submit" disabled>Post comment</button></form><p><a href="/library/record/?kind={singular}&amp;id={esc(rid)}">Submit a data correction or verification evidence →</a></p><noscript><p>Enable JavaScript to read or post comments.</p></noscript></section><!-- library-discussion:end -->'''
def detail(kind,r,description,facts,sections):
 tabs=''.join(f'<a href="#section-{n}">{esc(title)}</a>' for n,(title,_) in enumerate(sections))
 body=f'<nav class="db-breadcrumb" aria-label="Breadcrumb"><a href="/library/">Database</a> / <a href="/library/?tab={kind}">{esc(kind.title())}</a> / {esc(r["name"])}</nav><header class="db-record-hero"><p class="section-kicker">{kind.rstrip("s").title()} · #{r.get("numericId",r["id"])}</p><h1>{esc(r["name"])}</h1><p>{esc(description)}</p></header>{notice}<nav class="db-tabs" aria-label="Record sections"><a href="#facts">Quick facts</a>{tabs}<a href="#comments">Comments</a></nav><div class="db-record-layout"><aside id="facts" class="db-facts"><h2>Quick facts</h2><dl>{facts}</dl><a href="{r["sourceUrl"]}" target="_blank" rel="noopener noreferrer">External cross-reference ↗</a><p><a href="/library/sources/">Snapshot source &amp; license</a></p></aside><article>'+''.join(f'<div id="section-{n}">{section(title,body)}</div>' for n,(title,body) in enumerate(sections))+'</article></div>'+discussion(kind,r)
 return shell(r['name'],description,body,f'<script src="/assets/library/comments.js?v={VERSION}"></script>')
for i in items:
 if i.get('editorialOnly'): continue
 related=[qby[qid] for qid in i['questIds'] if qid in qby]
 statbody='<ul>'+''.join(f'<li>{s["value"]:+} {esc(s["name"])}</li>' for s in i['stats'])+'</ul>' if i['stats'] else '<p>No primary attribute bonuses recorded.</p>'
 if i['armor']:statbody+=f'<p>{i["armor"]} Armor</p>'
 if i['block']:statbody+=f'<p>{i["block"]} Block</p>'
 if i['damageMax']:statbody+=f'<p>{i["damageMin"]:g}–{i["damageMax"]:g} damage · {i["speed"]:g}s speed'+(f' · {(i["damageMin"]+i["damageMax"])/2/i["speed"]:.1f} DPS' if i['speed'] else '')+'</p>'
 statbody+='<p>Values describe the reference snapshot. Random enchantments and spell-based effects are not expanded here.</p>'
 facts=fact('Quality',i['quality'])+fact('Category',i['category'])+fact('Slot',i['slot'])+fact('Item level',i['itemLevel'])+fact('Required level',i['requiredLevel'])+fact('Binding',i['binding'])+fact('Profession',i['requiredSkill'])+(fact('Skill required',i['requiredSkillRank']) if i['requiredSkillRank'] else '')+fact('Stack size',i['stackSize'])
 sections=[('Stats & requirements',statbody),('Related quests',links(related)),('Sold by',links(nby[nid] for nid in i['vendorIds'])),('Dropped by',links(nby[nid] for nid in i['dropNpcIds'])+'<p>Direct creature-loot associations only. Shared loot pools, containers and scripted rewards may add other sources; no drop percentage is inferred.</p>'),('Player notes & verification',f'<p><a class="button button-primary" href="/library/record/?kind=item&id={i["id"]}">Read notes or submit evidence</a></p><button type="button" data-record-item="{i["id"]}">Add to my collection</button>')]
 sections += [('Contained in world objects',links(extra_catalogs['item_objects'][i['id']])+ '<p>Direct container-loot associations only; no drop rate is inferred.</p>'),('Crafting & spell connections',links(extra_catalogs['item_spells'][i['id']]))]
 save('items/'+str(i['id'])+'/index.html',detail('items',i,f'{i["quality"]} {i["category"].lower()} · {i["slot"]}. Browse requirements, structured stats, vendors, direct loot relations and linked quests.',facts,sections))
for q in quests:
 obj='<ul>'+''.join('<li>'+(link(iby[o['id']]) if o['type']=='Item' and o['id'] in iby else link(nby[o['id']]) if o['type'] in ('Creature','Action') and o['id'] in nby else link(oby[o['id']]) if o['type']=='Object' and o['id'] in oby else esc(o['name']))+f' × {o.get("count",1)} <small>{esc(o["type"])}</small></li>' for o in q.get('objectives',[]))+'</ul>' if q.get('objectives') else '<p>Check the current quest log for any scripted objectives.</p>'
 rewards=''
 for choice,label in [(False,'Guaranteed items'),(True,'Choose one reward')]:
  rewards+='<h3>'+label+'</h3>'+links(iby[r.get('id',r.get('itemId'))] for r in q.get('rewards',[]) if bool(r.get('choice'))==choice and r.get('id',r.get('itemId')) in iby)
 chain=list(dict.fromkeys(abs(q.get(k) or 0) for k in ('previousQuestId','nextQuestId','nextQuestInChain')))
 chainbody='<p>Recorded prerequisite and follow-up relationships. A negative prerequisite may represent a special condition; these links are not a complete eligibility solver.</p>'+links(qby[qid] for qid in chain if qid in qby and qid!=q.get('numericId'))
 people='<h3>Starts with</h3>'+links(nby[r['id']] for r in q.get('startSources',[]) if r['type']=='NPC' and r['id'] in nby)+'<h3>Return to</h3>'+links(nby[r['id']] for r in q.get('endSources',[]) if r['type']=='NPC' and r['id'] in nby)+f'<p>Recorded start: {esc(q.get("starts"))}. Recorded end: {esc(q.get("ends","See quest log"))}.</p>'
 facts=fact('Zone / category',q['zone'])+fact('Faction',q['faction'])+fact('Type',q['type'])+fact('Minimum level',q['minLevel'])+fact('Quest level',q.get('questLevel'))+fact('Planning band',f'{q["minLevel"]}–{q["maxLevel"]}')+fact('Classes',', '.join(q.get('classRestrictions',[])))+fact('Repeatable','Yes' if q.get('repeatable') else 'No repeat flag recorded')
 body=detail('quests',q,q['summary'],facts,[('Objectives',obj),('Rewards',rewards+'<p>Choice rewards are alternatives, not a bundle. Currency and spell rewards are not expanded in this item list.</p>'),('Quest connections',chainbody),('Quest NPCs',people),('Route & player notes',f'<p><a href="{q["zoneUrl"]}">Open {esc(q["zone"])} route</a></p><p>{esc(q.get("availabilityNotes", "Use the minimum level and current quest log to check eligibility. Planning bands are guidance, not eligibility ceilings."))}</p><p><a href="/library/record/?kind=quest&id={q["id"]}">Read notes or submit evidence →</a></p><button type="button" data-record-quest="{esc(q["id"])}">Mark complete</button>')])
 save(q['recordUrl'].strip('/')+'/index.html',body)
for n in npcs:
 sections=[(label+' quests',links(qs)) for label,qs in npcquests[n['id']].items()]
 sections += [(label,links(rows)) for label,rows in npcitems[n['id']].items()]
 if not sections:sections=[('Reference notes','<p>This creature is present in the snapshot. No quest, vendor or direct item relationship is recorded in this index.</p>')]
 sections.append(('Training & spells',links(extra_catalogs['npc_spells'][n['id']])))
 sections.append(('Coverage','<p>Creature levels and roles come from the Classic snapshot. Locations and spawn timers are not presented as verified Forever information.</p>'))
 save(n['recordUrl'].strip('/')+'/index.html',detail('npcs',n,n['subtitle'] or n['type'],fact('Level',f'{n["minLevel"]}–{n["maxLevel"]}')+fact('Rank',n['rank'])+fact('Creature type',n['type'])+fact('Roles',', '.join(n['roles']) or 'Creature'),sections))

for record in world_objects:
 objquests=record['questLinks']
 facts=fact('Category',record['type'])+fact('Related zones',', '.join(record['zones']))+fact('Linked quests',len(objquests))
 content=[('Quest connections',links(objquests)),('Recorded contents',links(iby[iid] for iid in record['itemIds'])+'<p>Only direct container-loot entries are included. Shared pools, conditions, loot chance and spawn locations are not inferred.</p>')]
 save(record['recordUrl'].strip('/')+'/index.html',detail('objects',record,'Interactive world object reference with quest connections and recorded container contents.',facts,content))
for record in spells:
 reagents='<ul>'+''.join('<li>'+(link(iby[r['id']]) if r['id'] in iby else esc(r['name']))+f' × {r["count"]}</li>' for r in record['reagents'])+'</ul>' if record['reagents'] else '<p>No item reagents recorded for this spell.</p>'
 facts=fact('School',record['school'])+fact('Rank',record['rank'])+fact('Category',record['category'])+(fact('Training level',record['level']) if record['level'] else '')+(fact('Base mana cost',record['manaCost']) if record.get('powerType')==0 and record['manaCost'] else '')+(fact('Spell cooldown',f'{record["cooldownSeconds"]:g} seconds') if record['cooldownSeconds'] else '')
 content=[('Learns',links(sby[sid] for sid in record.get('learnedSpellIds',[]) if sid in sby)+'<p>Learning effects teach the linked player spell. School, costs and reagents belong to that learned spell.</p>')] if record.get('learnedSpellIds') else []
 content += [('Reagents',reagents),('Creates',links(iby[iid] for iid in record['outputItemIds'])+'<p>Created-item associations do not imply a fixed yield; quantities and conditions can vary.</p>'),('Trainers',links(nby[nid] for nid in record['trainerIds'])),('Recipe items',links(iby[iid] for iid in record['recipeItemIds'])),('Reference notes','<p>This catalog includes selected trained and starting spells, recipe-linked spells and item-creation effects. An item-creation effect is not necessarily a profession recipe. Skill, class, quest and rank conditions may also apply. Costs and effects can change in Forever.</p>')]
 save(record['recordUrl'].strip('/')+'/index.html',detail('spells',record,'Spell and crafting reference with reagents, created items, recorded trainers and recipe connections.',facts,content))
# Retained editorial item pages use the same discussion as their catalog entry.
for item in items:
 if not item.get('editorialOnly'):continue
 path=ROOT/item['recordUrl'].strip('/')/'index.html';page=path.read_text()
 page=re.sub(r'<!-- library-discussion:start -->[\s\S]*?<!-- library-discussion:end -->','',page)
 page=page.replace('</main>',discussion('items',item)+'</main>',1)
 if 'comments.css' not in page:page=page.replace('</head>',f'<link rel="stylesheet" href="/assets/library/comments.css?v={VERSION}"></head>')
 page=re.sub(r'<script src="/assets/library/comments.js[^"]*"></script>','',page)
 page=page.replace('</body>',f'<script src="/assets/library/comments.js?v={VERSION}"></script></body>')
 page=re.sub(r'/app.js\?v=[^"]*',f'/app.js?v={VERSION}',page);path.write_text(page)

# Lightweight list data contains only browser columns, not the detailed records.
def row(r,kind):
 return {'id':r.get('numericId',r['id']),'name':r['name'],'url':r['recordUrl'],'category':r.get('category',r.get('type','')),'level':r.get('requiredLevel',r.get('minLevel',r.get('level',0))) or 0,'levelUnknown':kind=='objects' or (kind in ('items','recipes') and r.get('requiredLevel') is None),'maxLevel':r.get('maxLevel'),'quality':r.get('school','') if kind=='spells' else r.get('quality',r.get('rank','')),'slot':r.get('slot',''),'zone':r.get('zone',''),'zones':r.get('zones',[]),'faction':r.get('faction',''),'profession':r.get('requiredSkill',''),'roles':', '.join(r.get('roles',[]))}
recipes=[i for i in items if i['classId']==9]
for kind,rows in [('items',items),('quests',quests),('npcs',npcs),('recipes',recipes),('objects',world_objects),('spells',spells)]: dump('assets/library/'+kind+'.json',[row(r,kind) for r in rows])
counts={'items':len(items),'quests':len(quests),'npcs':len(npcs),'recipes':len(recipes),'objects':len(world_objects),'spells':len(spells)}
provenance=json.loads((ROOT/'quests/provenance.json').read_text()); provenance.update({'expandedAt':DATE,'expandedDbSha256':hashlib.sha256(Path(args.database).read_bytes()).hexdigest(),'expandedCoverage':'Original-world items requiring level 60 or below, retained editorial entries, quests across both continents and special categories, and spawned or quest-linked NPCs. Development placeholders are excluded. Recipe entries are recipe items, not a complete learned-spell database. NPC and object loot links include direct loot rows only. Additional categories cover interactive world objects and selected trained, starting, recipe-linked and item-creation spells. Every record supports its own public discussion.','expandedCounts':counts})
dump('library/provenance.json',provenance)
dump('quests/quests.json',{'updated':DATE,'coverage':provenance['expandedCoverage'],'quests':quests})
dump('items/items.json',{'updated':DATE,'coverage':provenance['expandedCoverage'],'items':items})
dump('output/world-library/npcs.json',npcs)
for kind,records in [('npcs',npcs),('objects',world_objects),('spells',spells)]:dump('library/data/'+kind+'.json',{'records':records})
dump('output/world-library/counts.json',counts)
new_paths={i['recordUrl'].strip('/')+'/index.html' for i in items+quests+npcs+world_objects+spells}
for stale in previous_paths-new_paths:
 p=ROOT/stale
 if p.exists() and 'database-page' in p.read_text(): p.unlink()
dump('output/world-library/manifest.json',{'paths':[i['recordUrl'].strip('/')+'/index.html' for i in items]+[q['recordUrl'].strip('/')+'/index.html' for q in quests]+[n['recordUrl'].strip('/')+'/index.html' for n in npcs]+[r['recordUrl'].strip('/')+'/index.html' for r in world_objects+spells]})

def browser_page(title,initial='items'):
 cards=''.join(f'<a class="db-category" href="/library/?tab={kind}"><strong>{count:,}</strong><span>{dict(npcs="NPCs & creatures",objects="World objects",spells="Spells & crafting").get(kind,kind.title())}</span></a>' for kind,count in counts.items())
 body=f'<header class="db-hero"><p class="section-kicker">The Forever database</p><h1>Know the world.<br><em>Plan your journey.</em></h1><p>Search items and quest connections, explore NPCs and world objects, and connect spells to recipes, reagents and created items.</p></header><div class="db-category-grid">{cards}</div>{notice}<section data-database-browser data-initial-tab="{initial}"><nav class="db-tabs" aria-label="Database category">'+''.join(f'<button type="button" data-db-tab="{kind}" aria-pressed="false">{dict(npcs="NPCs & creatures",objects="World objects",spells="Spells & crafting").get(kind,kind.title())}</button>' for kind in counts)+'</nav><form class="db-filters" data-db-form><label class="db-search">Search by name or ID<input type="search" name="q" placeholder="Try Thunderfury, Elwynn or 19019" autocomplete="off"></label><label>Category<select name="category"><option value="">All categories</option></select></label><label>Quality / rank / school<select name="quality"><option value="">All qualities</option></select></label><label>Zone / profession<select name="zone"><option value="">All zones</option></select></label><label>Minimum level<input type="number" name="min" min="0" max="65" placeholder="0"></label><label>Maximum level<input type="number" name="max" min="0" max="65" placeholder="65"></label><label>Sort<select name="sort"><option value="name">Name A–Z</option><option value="level">Level: low first</option><option value="level-desc">Level: high first</option></select></label><button type="reset">Reset</button></form><p class="db-results-summary" data-db-status role="status" aria-live="polite">Loading database…</p><div class="db-table-wrap" tabindex="0" role="region" aria-label="Database results"><table class="db-table"><thead><tr><th scope="col">Name</th><th scope="col">Category</th><th scope="col">Level</th><th scope="col">Quality / rank / school</th><th scope="col">Zone / details</th></tr></thead><tbody data-db-results></tbody></table></div><nav class="db-pagination" aria-label="Result pages"><button type="button" data-db-prev>Previous</button><span data-db-page></span><button type="button" data-db-next>Next</button></nav><noscript><p>Enable JavaScript to filter the database, or open the <a href="/sitemap/">site directory</a> for individual record pages.</p></noscript></section>'
 return shell(title,'Search the Forever reference database: items, quests, recipes, NPCs, requirements and linked sources.',body,f'<script src="/assets/library/database.js?v={VERSION}"></script>')
save('library/index.html',browser_page('Database'))
save('npcs/index.html',browser_page('NPCs & Creatures','npcs'))
save('recipes/index.html',browser_page('Recipe Database','recipes'))
save('objects/index.html',browser_page('World Objects','objects'))
save('spells/index.html',browser_page('Spells & Crafting','spells'))
save('library/sources/index.html',shell('Database Sources & Coverage','Sources, coverage and downloadable structured data for the Forever database.',f'<header class="db-record-hero"><p class="section-kicker">Database research trail</p><h1>Sources &amp; coverage</h1></header>{notice}'+section('What is included',f'<p>{esc(provenance["expandedCoverage"])}</p><p>{counts["items"]:,} items · {counts["quests"]:,} quests · {counts["npcs"]:,} NPCs and creatures · {counts["recipes"]:,} recipe items (a subset of items) · {counts["objects"]:,} world objects · {counts["spells"]:,} selected spells and item-creation effects.</p>')+section('Snapshot attribution','<p>Structured baseline adapted from CMaNGOS Classic-DB contributors, distributed under GNU GPL version 3. No warranty is provided. Modified for this library on 15 October 2026: selected factual fields, generated summaries, removed development placeholders, and added internal relations. Quest dialogue and walkthrough prose are not reproduced.</p><p><a href="https://github.com/cmangos/classic-db">Upstream source</a> · <a href="https://github.com/cmangos/classic-db/blob/master/LICENSE.md">License</a> · <a href="/library/provenance.json">Pinned provenance and SHA-256</a></p>')+section('Data downloads','<p>Structured data is provided with the same GPL-3.0 terms as its upstream baseline. Existing original editorial notes remain attributed to WoW Forever Friends.</p><p><a href="/items/items.json">Items JSON</a> · <a href="/quests/quests.json">Quests JSON</a> · <a href="/library/data/npcs.json">NPC records JSON</a> · <a href="/library/data/objects.json">World objects JSON</a> · <a href="/library/data/spells.json">Spells JSON</a> · <a href="/library/source/build-world-library.py">Transformation source</a> · <a href="/library/source/world_library_extras.py">Additional catalogs source</a> · <a href="/library/source/LICENSE.md">License text</a></p>')+section('Blizzard enrichment','<p>The live item library can enrich supported records from Blizzard’s Classic Game Data API. API imports are reference data, not confirmation of availability in Forever. The Classic API does not currently expose quests. Item detail pages show snapshot statistics; API refreshes appear in the live community record.</p>')+section('Verification & limitations','<p>Baseline relations do not establish live availability, drop rates or spawn times. Shared loot pools are not expanded. Quest chains may have additional reputation, skill, class or race conditions. Historical records are labelled in the quest category. Compare with your current client before committing to a route.</p><p><a href="/contribute/">Submit in-game evidence</a> · <a href="/library/">Return to database</a></p>')))
node_binary=shutil.which('node')
if node_binary:
 subprocess.run([node_binary, str(ROOT/'scripts/apply-page-images.mjs')], cwd=ROOT, check=True)
 subprocess.run([node_binary, str(ROOT/'scripts/build-seo.mjs')], cwd=ROOT, check=True)
else:
 print('Warning: node was not found; run the page-image and SEO builders separately.')
save('library/source/build-world-library.py',Path(__file__).read_text())
save('library/source/world_library_extras.py',(ROOT/'scripts/world_library_extras.py').read_text())
subprocess.run([__import__('sys').executable,str(ROOT/'scripts/enrich-item-library.py'),'--database',args.database],cwd=ROOT,check=True)
save('library/source/enrich-item-library.py',(ROOT/'scripts/enrich-item-library.py').read_text())
print(json.dumps(counts))
