from pathlib import Path
import re,json,unicodedata,sys
import pandas as pd
R=Path(__file__).resolve().parent;ROOT=R.parent
HOSTS={
'Action roguelike':['Action Roguelike'],
'Deckbuilding':['Roguelike Deckbuilder','Deckbuilding'],
'Auto battler':['Auto Battler'],
'Action RPG':['Action RPG'],
'Tactical RPG':['Tactical RPG','Strategy RPG','Party-Based RPG'],
'Tower defense':['Tower Defense'],
'Platforming':['Platformer','2D Platformer','3D Platformer','Precision Platformer'],
'Metroidvania':['Metroidvania'],
'First-person shooting':['FPS','Boomer Shooter'],
'Top-down/arena shooting':['Top-Down Shooter','Twin Stick Shooter','Arena Shooter'],
'Shoot-em-up':['Shoot \'Em Up','Bullet Hell'],
'Racing':['Racing','Combat Racing'],
'Driving simulation':['Driving','Automobile Sim','Offroad'],
'Flight/space simulation':['Flight','Space Sim'],
'Sports':['Sports','Golf','Mini Golf','Football (Soccer)','Football (American)','Basketball','Tennis','Hockey','Baseball'],
'Rhythm':['Rhythm'],
'Stealth/heist':['Stealth','Heist'],
'Horror':['Horror','Psychological Horror','Survival Horror'],
'Survival/crafting':['Survival','Open World Survival Craft'],
'Puzzle':['Puzzle','Logic','Sokoban','Puzzle Platformer'],
'Match-3':['Match 3'],
'Pinball':['Pinball'],
'Board/dice/tabletop':['Board Game','Dice','Tabletop'],
'City/colony building':['City Builder','Colony Sim'],
'Automation':['Automation'],
'Management':['Management','Resource Management'],
'Farming':['Farming','Farming Sim'],
'Fishing':['Fishing'],
'Fighting':['Fighting','2D Fighter','3D Fighter'],
'Beat-em-up':['Beat \'em up','Character Action Game','Spectacle fighter'],
'Real-time strategy':['RTS','Real Time Tactics','Action RTS'],
'Turn-based strategy':['Turn-Based Strategy','Turn-Based Tactics','4X'],
'Adventure/point-and-click':['Point & Click','Escape Room','Walking Simulator'],
'Job/hobby simulation':['Job Simulator','Hobby Sim','Cooking','Shop Keeper'],
}
CORE={'Action Roguelike','Roguelike Deckbuilder','Deckbuilding','Auto Battler','Action RPG','Party-Based RPG','Tactical RPG','Strategy RPG'}
ROGUE={'Roguelike','Roguelite','Action Roguelike','Roguelike Deckbuilder','Traditional Roguelike'}
AUDIT_HOSTS=['Platforming','First-person shooting','Racing','Sports','Flight/space simulation','Rhythm','Stealth/heist','Horror','Puzzle','Management','Fighting','Job/hobby simulation']
HOST_WORDS={
'Action roguelike':r'rogue|survivors?[- ]like|bullet heaven',
'Deckbuilding':r'deck|cards?',
'Auto battler':r'auto[- ]?battl|autochess|auto[- ]?chess|automatic(?:ally)? (?:fight|battl|combat)',
'Action RPG':r'\bRPG\b|role[- ]playing|action[- ]rpg',
'Tactical RPG':r'\b(?:RPG|TRPG)\b|role[- ]playing|tactical (?:combat|battl)|turn[- ]based',
'Tower defense':r'tower[- ]defen[cs]e|defen[cs]e towers?',
'Platforming':r'platform(?:er|ing)|side[- ]scroll|parkour|jump(?:ing)? (?:over|across|between|from)|double[- ]jump|wall[- ]jump',
'Metroidvania':r'metroidvania|metroid[- ]like|castlevania',
'First-person shooting':r'first[- ]person|\bFPS\b|boomer shooter|bodycam',
'Top-down/arena shooting':r'top[- ]down|twin[- ]stick|arena shooter|arena[- ]shoot|bullet[- ](?:hell|heaven)',
'Shoot-em-up':r'shoot.?em.?up|shmup|bullet[- ](?:hell|heaven)',
'Racing':r'\bracing\b|\bracers?\b|race ?tracks?|\brace\b.{0,60}\b(?:cars?|vehicles?|karts?|circuits?|laps?)\b',
'Driving simulation':r'driv(?:e|ing)|cars?|vehicles?|truck|offroad|off-road|automobile',
'Flight/space simulation':r'flight|space sim|pilots?|aircraft|airplanes?|spaceships?|starships?|spacecraft|gunship',
'Sports':r'sports?|football|soccer|golf|basketball|baseball|tennis|hockey|box(?:er|ing)|athletes?|skate|bowling|billiards',
'Rhythm':r'rhythm[- ](?:game|based|action|shooter|rogue|platform|combat|mechanic)|on (?:the )?beat|to the (?:music|beat|rhythm)|rhythmic|music[- ]driven|karaoke|rhythm and',
'Stealth/heist':r'stealth|heist|sneak|avoid detection|hide from|hiding from',
'Horror':r'horror|spooky|haunted|terrifying|nightmar',
'Survival/crafting':r'surviv|crafting',
'Puzzle':r'puzzl|sokoban|logic[- ](?:game|duel)',
'Match-3':r'match[- ]?3|match[- ]three|matching (?:three|3)',
'Pinball':r'pinball',
'Board/dice/tabletop':r'board ?game|tabletop|dice|poker|chess|mahjong|solitaire|domino',
'City/colony building':r'city[- ]build|colony|colonies|settlement|build.{0,30}(?:city|town|cities)',
'Automation':r'automat|factor(?:y|ies)|production lines?',
'Management':r'manag|resource|econom(?:y|ic)',
'Farming':r'farm|crops?|agricultur',
'Fishing':r'fish(?:ing|er|es)?\b|angling',
'Fighting':r'fighting game|versus fighting|platform fighter|\bbrawler\b|\bboxing\b|martial arts|2D fight|3D fight',
'Beat-em-up':r'beat.?em.?up|hack.?and.?slash|character action|spectacle fighter',
'Real-time strategy':r'real[- ]time (?:strategy|tactic)|\bRTS\b|command.{0,60}real[- ]time',
'Turn-based strategy':r'turn[- ]based|\b4X\b|tactical (?:combat|battles?)',
'Adventure/point-and-click':r'point.?and.?click|walking sim|escape room|escape[- ]game',
'Job/hobby simulation':r'simulat|cook|shopkeep|restaurant|workshop|job|business|store',
}
OBJ=r'(?:weapons?|skills?|abilities|ability|passives?|items?|equipment|gear|armor|armour|companions?|upgrades?|cards?|relics?|characters?|heroes|classes|runes?|augments?|perks?|traits?|talents?|units?|spells?|gadgets?|modules?|loadouts?|decks?|artifacts?|components?|parts)'
O=re.compile(r'\b'+OBJ+r'\b',re.I)
PATTERNS={
'build':r'\b(?:(?:unique|different|diverse|varied|custom|viable|powerful|crazy|absurd|broken|overpowered|unlimited|endless|distinct|creative|strategic)(?:[ -]+(?:character|hero|weapon|equipment|combat|skill|party|team|mech|deck|loadout|endgame)){0,2}[ -]+builds?|(?:character|hero|weapon|equipment|combat|skill|party|team|mech|loadout)[ -]+builds?|build[- ]?craft(?:ing)?|build[- ](?:changing|defining|enabling)|build (?:diversity|variety|synergies)|your (?:own |unique |ultimate )?build)\b',
'deck':r'\b(?:deck[- ]?build\w*|(?:build|craft|construct|customiz(?:e|ing|ed))\b.{0,45}\bdecks?|draft\w*\b.{0,50}\bcards?)\b',
'synergy':r'\b(?:synerg\w*|mix and match)\b.{0,100}\b'+OBJ+r'\b|\b'+OBJ+r'\b.{0,100}\b(?:synerg\w*|mix and match)\b',
'loadout':r'\b(?:loadouts?|playstyles?|play styles?)\b.{0,100}\b'+OBJ+r'\b|\b'+OBJ+r'\b.{0,100}\b(?:loadouts?|playstyles?|play styles?)\b',
'choice':r'\b(?:equip|customiz\w*|specializ\w*|experiment|choose|choosing|select|swap|switch|draft\w*|loadouts?)\b',
'branch':r'\b(?:multiclass\w*|multi-class\w*|class combinations|hybrid classes|branching classes|subclasses|sub-classes)\b',
'combination':r'\b(?:combin\w*)\b.{0,90}\b'+OBJ+r'\b|\b'+OBJ+r'\b.{0,90}\b(?:combin\w*)\b',
'party':r'\b(?:assemble|compose|customiz\w*|recruit|draft\w*)\b.{0,70}\b(?:party|team|squad|army|heroes|units)\b',
'lineup':r'\b(?:assemble|compose|customiz\w*|recruit|draft\w*|choose|match|select|build)\b.{0,60}\b(?:lineup|roster|troops|team|army|squad)\b',
'tree':r'\b(?:skill|passive|talent) trees?\b',
'parts':r'\b(?:tune|tuning|customiz\w*|modif\w*|adjust\w*|swap|equip|configur\w*)\b.{0,100}\b(?:engines?|tires?|tyres?|suspension|gear ratios?|components?|modules?|parts|attachments|equipment|armaments?|ships?|cars?|vehicles?|attributes|stats)\b|\b(?:choose|select)\b.{0,80}\b(?:armaments?|weapon attachments|equipment)\b',
'performance':r'\b(?:handling|performance|acceleration|traction|grip|downforce|stats|attributes|capabilities|abilities|firepower|playstyle|synerg\w*|trade-offs?)\b',
}
P={k:re.compile(v,re.I) for k,v in PATTERNS.items()}

def classify(short,description):
 text=(short or '')+' '+(description or '')
 # Game-engine and screenshot/video terminology is not loadout customization.
 text=re.sub(r'\b(?:Unreal(?: Engine)?(?: [0-9.]+)?|(?:modified |customized |custom )?(?:DOOM|Unity|game) engine(?: [0-9.]+)?)\b',' ',text,flags=re.I)
 hits={k:list(p.finditer(text)) for k,p in P.items()}
 has=lambda k:bool(hits[k])
 objects=set(m.group().lower() for m in O.finditer(text))
 build_local=any(O.search(text[max(0,m.start()-120):m.end()+120]) for m in hits['build'])
 # No genre membership is required by any positive rule.
 deck=has('deck') and not (re.search(r'\b(?:wood|wooden|nails|hammer)\b',text,re.I) and not re.search(r'\bcards?\b',text,re.I))
 synergy=False
 for m in hits['synergy']:
  local=text[max(0,m.start()-100):m.end()+100]
  cosmetic=re.search(r'\b(?:cosmetic\w*|skins?|customizations?|outfits?|appearance|stickers)\b',local,re.I)
  effect=re.search(r'\b(?:damage|stats|attack|combat|weapons?|effects?|functions?|synerg\w*)\b',local,re.I)
  if not cosmetic or effect:synergy=True
 loadout=has('loadout') and bool(re.search(r'\bloadouts?\b',text,re.I))
 strict=(build_local or deck or (synergy and has('choice') and len(objects)>=2)
         or (loadout and has('choice') and len(objects)>=2) or has('branch'))
 functional_parts=any(P['performance'].search(text[max(0,m.start()-100):m.end()+140]) for m in hits['parts'])
 lineup=has('lineup') and bool(re.search(r'\b(?:lineup|roster|strengths|weaknesses|counter|synerg\w*|formation)\b',text,re.I))
 extended=(strict or (has('loadout') and has('choice') and len(objects)>=3)
           or (has('combination') and has('choice') and (has('parts') or has('tree') or has('party')))
           or (has('party') and has('choice') and (has('tree') or has('combination'))) or lineup
           or (has('parts') and functional_parts) or (has('tree') and has('choice')))
 direct=build_local or deck
 evidence={k:[text[max(0,m.start()-70):m.end()+140] for m in hits[k][:2]] for k in ['build','deck','synergy','loadout','branch','party','tree','parts','performance'] if hits[k]}
 return dict(strict=bool(strict),extended=bool(extended),direct=bool(direct),evidence=json.dumps(evidence,ensure_ascii=False))

def norm(s):return re.sub(r'\s+',' ',unicodedata.normalize('NFKC',str(s)).casefold()).strip()
def load_source():
 sys.path.insert(0,str(ROOT/'portfolios-2026-09-06'))
 from source_data import load_games
 g=load_games()
 old=pd.read_parquet(ROOT/'build-expression-2026-09-05/classification.parquet',columns=['appid','strict','exclusion']).rename(columns={'strict':'old_strict'})
 g=g.merge(old,on='appid',validate='one_to_one')
 g['eligible']=g.main&g.exclusion.eq('')
 g['developer_id']=['name:'+norm(n) if str(n).strip() else 'unknown:'+str(a) for n,a in zip(g.developer,g.appid)]
 g['publisher_id']=['name:'+norm(n) if str(n).strip() else 'unknown:'+str(a) for n,a in zip(g.publisher,g.appid)]
 g['text_words']=(g.short_description.fillna('')+' '+g.description.fillna('')).str.split().str.len()
 g['text_bin']=pd.cut(g.text_words,[-1,150,300,600,float('inf')],labels=['<=150','151-300','301-600','>600']).astype(str)
 g['date_bin']=g.quarter
 m=g.year.eq(2026).fillna(False)
 g.loc[m,'date_bin']=g.loc[m,'first_date'].dt.strftime('%Y-%m')+'-'+g.loc[m,'first_date'].dt.day.map(lambda d:'01' if d<=15 else '16')
 g['period']='other'
 g.loc[g.year.between(2019,2022),'period']='2019_2022'
 g.loc[g.year.between(2023,2025),'period']='2023_2025'
 g.loc[g.year.eq(2026)&g.month.le(8),'period']='2026_JanAug'
 return g,pd.read_parquet(ROOT/'analysis-2026-09-05/tags.parquet')
