#!/usr/bin/env python3
"""Static checks of the bot data files (run from anywhere, no server needed).

- FakePlayerChatLines.xml / FakePlayerPools.xml: every #pool# exists, no cycles,
  every {slot} is one the code can fill, morph brackets are balanced;
- every seam the Java code or the intent lexicon refers to exists in the XML;
- FakePlayerCombat.xml: every skill id is learnable by that class (or one of its
  parent classes) according to the datapack skill trees, and every class id
  used by the seeded characters has a profile.

Usage: python tools/check_data.py [--skilltrees PATH] [--seed PATH]
Exit code 1 on errors.
"""
import argparse
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / 'src_mobius' / 'mobius' / 'L2J_Mobius_CT_0_Interlude'
DATA = SRC / 'dist' / 'game' / 'data'
JAVA = SRC / 'java' / 'org' / 'l2jmobius' / 'gameserver'

KNOWN_SLOTS = {'player', 'target', 'level', 'boss', 'zone', 'town', 'msg', 'prev', 'enemyclan', 'mob', 'castle',
               'clan', 'class', 'drop', 'item', 'price', 'crowd', 'daytime'}
# Seams the code calls by a literal name (kept in sync with the harness list).
CODE_SEAMS = {
    'greeting', 'whisper_default', 'party_accept', 'reply_insult_soft', 'reply_insult_aggro', 'reply_caps',
    'reply_again', 'revenge_meet', 'friendly_meet', 'gank_start', 'pkk_start', 'war_start', 'assist',
    'companion_done', 'invite_player', 'ambient', 'lfp', 'levelup', 'grats', 'overheard', 'overheard_q',
    'killed_by', 'died_mob', 'migrate', 'profession', 'store_title', 'trade_shout', 'call_help', 'raid_start',
    'bot_talk', 'siege_start', 'siege_defend', 'siege_gate', 'siege_engrave', 'siege_win', 'siege_lose',
    'reply_help_yes', 'reply_help_no', 'loot_brag',
}
# Class transfer chains (Interlude): child -> parent.
PARENT = {1: 0, 2: 1, 3: 1, 4: 0, 5: 4, 6: 4, 7: 0, 8: 7, 9: 7, 11: 10, 12: 11, 13: 11, 14: 11, 15: 10, 16: 15,
          17: 15, 19: 18, 20: 19, 21: 19, 22: 18, 23: 22, 24: 22, 26: 25, 27: 26, 28: 26, 29: 25, 30: 29, 32: 31,
          33: 32, 34: 32, 35: 31, 36: 35, 37: 35, 39: 38, 40: 39, 41: 39, 42: 38, 43: 42, 45: 44, 46: 45, 47: 44,
          48: 47, 50: 49, 51: 50, 52: 50, 54: 53, 55: 54, 56: 53, 57: 56,
          88: 2, 89: 3, 90: 5, 91: 6, 92: 9, 93: 8, 94: 12, 95: 13, 96: 14, 97: 16, 98: 17, 99: 20, 100: 21,
          101: 23, 102: 24, 103: 27, 104: 28, 105: 30, 106: 33, 107: 34, 108: 36, 109: 37, 110: 40, 111: 41,
          112: 43, 113: 46, 114: 48, 115: 51, 116: 52, 117: 55, 118: 57}

errors = []
warnings = []


def err(msg):
    errors.append(msg)


def warn(msg):
    warnings.append(msg)


def check_chat():
    lines_xml = (DATA / 'FakePlayerChatLines.xml').read_text(encoding='utf-8')
    pools_xml = (DATA / 'FakePlayerPools.xml').read_text(encoding='utf-8')
    seams = {}
    for m in re.finditer(r'<seam key="([^"]+)"[^>]*>(.*?)</seam>', lines_xml, re.S):
        seams[m.group(1)] = re.findall(r'<line[^>]*>(.*?)</line>', m.group(2), re.S)
    pools = {}
    for m in re.finditer(r'<pool key="([^"]+)"[^>]*>(.*?)</pool>', pools_xml, re.S):
        pools[m.group(1)] = re.findall(r'<line[^>]*>(.*?)</line>', m.group(2), re.S)
    total_lines = sum(len(v) for v in seams.values())
    total_pool = sum(len(v) for v in pools.values())
    print(f'seams: {len(seams)} ({total_lines} lines), pools: {len(pools)} ({total_pool} lines)')

    def scan(text, where):
        for ref in re.findall(r'#([^#\s]+)#', text):
            if ref not in pools:
                err(f'{where}: unknown pool #{ref}#')
        for slot in re.findall(r'\{([a-z_]+)\}', text):
            if slot not in KNOWN_SLOTS:
                err(f'{where}: unknown slot {{{slot}}}')
        for open_, close in (('[', ']'), ('(', ')')):
            if text.count(open_) != text.count(close):
                err(f'{where}: unbalanced {open_}{close} in "{text}"')
        if '{msg}' in text and where.split(':')[0] not in ('overheard', 'overheard_q', 'reply_again'):
            pass

    for key, lines in seams.items():
        if not lines:
            err(f'seam {key} is empty')
        for line in lines:
            scan(line, f'{key}:line')
    for key, lines in pools.items():
        if not lines:
            err(f'pool {key} is empty')
        for line in lines:
            scan(line, f'pool {key}')

    # Pool cycles.
    graph = {k: set(re.findall(r'#([^#\s]+)#', ' '.join(v))) for k, v in pools.items()}

    def cyclic(node, stack):
        if node in stack:
            return True
        for nxt in graph.get(node, ()):
            if cyclic(nxt, stack | {node}):
                return True
        return False

    for key in pools:
        if cyclic(key, set()):
            err(f'pool cycle through {key}')

    # Seams referenced by code and intents.
    intents_xml = (DATA / 'FakePlayerIntents.xml').read_text(encoding='utf-8')
    intent_seams = set(re.findall(r'seam="([^"]+)"', intents_xml)) - {''}
    for seam in sorted(CODE_SEAMS | intent_seams):
        if seam not in seams:
            err(f'seam referenced by code/intents but missing in XML: {seam}')
    # Seams referenced as literals in the Java sources (best effort).
    literal = set()
    for java in JAVA.rglob('FakePlayer*.java'):
        text = java.read_text(encoding='utf-8', errors='replace')
        for m in re.finditer(r'(?:eventLine|optionalLine|compose|speak|speakPrivate|pick|pickFor)\([^;]*?"([a-z_]+)"', text):
            literal.add(m.group(1))
    for seam in sorted(literal):
        if seam not in seams and seam not in ('personality', 'player', 'target', 'level', 'msg', 'boss', 'zone', 'neutral', 'ganker', 'item', 'price'):
            warn(f'literal "{seam}" used with the chat engine but not a seam (check the call)')
    unused = sorted(set(seams) - CODE_SEAMS - intent_seams)
    if unused:
        warn(f'seams not referenced by code or intents: {", ".join(unused)}')


def check_combat(skilltrees, seed):
    combat_xml = (DATA / 'FakePlayerCombat.xml').read_text(encoding='utf-8')
    profiles = {}
    for m in re.finditer(r'<class id="(\d+)"[^>]*>(.*?)</class>', combat_xml, re.S):
        profiles[int(m.group(1))] = [int(x) for x in re.findall(r'id="(\d+)"', m.group(2))]
    print(f'combat profiles: {len(profiles)} classes')
    if skilltrees and Path(skilltrees).is_dir():
        learnable = {}
        for f in Path(skilltrees).rglob('*.xml'):
            text = f.read_text(encoding='utf-8', errors='replace')
            for tree in re.finditer(r'<skillTree type="classSkillTree" classId="(\d+)"[^>]*>(.*?)</skillTree>', text, re.S):
                cid = int(tree.group(1))
                learnable.setdefault(cid, set()).update(int(x) for x in re.findall(r'skillId="(\d+)"', tree.group(2)))

        def chain(cid):
            out = set()
            while cid is not None:
                out |= learnable.get(cid, set())
                cid = PARENT.get(cid)
            return out

        for cid, skills in profiles.items():
            available = chain(cid)
            for sid in skills:
                if sid not in available:
                    err(f'combat profile class {cid}: skill {sid} is not learnable by this class chain')
    else:
        warn('skill trees not found, combat skill ids not verified (pass --skilltrees)')
    if seed and Path(seed).is_file():
        text = Path(seed).read_text(encoding='utf-8', errors='replace')
        classes = set()
        for m in re.finditer(r"'sppbots', '[^']*', (?:-?\d+, ){12}(\d+), \d+, \d+", text):
            classes.add(int(m.group(1)))
        missing = sorted(c for c in classes if c not in profiles)
        if missing:
            warn(f'seed classes without a combat profile (default profile is used): {missing}')
        else:
            print(f'all {len(classes)} seed classes have a combat profile')


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--skilltrees', default=str(ROOT / 'server' / 'game' / 'data' / 'stats' / 'players' / 'skillTrees'))
    parser.add_argument('--seed', default=str(SRC / 'dist' / 'db_installer' / 'sql' / 'spp_clan_seed.sql'))
    args = parser.parse_args()
    check_chat()
    check_combat(args.skilltrees, args.seed)
    for w in warnings:
        print('WARN', w)
    for e in errors:
        print('ERROR', e)
    print(f'{len(errors)} errors, {len(warnings)} warnings')
    sys.exit(1 if errors else 0)


if __name__ == '__main__':
    main()
