#!/usr/bin/env python3
"""Re-checks the bot farming zones (FakePlayerProgression.xml) against the real
monster spawns of the datapack and keeps only spots where the monsters match
the zone level band. Bots are real players now: a level 15 fighter dropped
next to a level 34 Turek Orc Elder simply dies.

For every zone point the monsters spawned within RADIUS are collected (point
spawns and territory spawns), the point is kept when the median monster level
fits the band and no dangerous pack (p75) sits above band+2. Zones that end
up with too few points get their band re-labelled to the real monster levels.

Usage: python tools/fix_zones.py [--apply] [--datapack server/game/data]
Without --apply only the report is printed.
"""
import argparse
import math
import re
import statistics
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
XML = ROOT / 'src_mobius' / 'mobius' / 'L2J_Mobius_CT_0_Interlude' / 'dist' / 'game' / 'data' / 'FakePlayerProgression.xml'
RADIUS = 1400
MIN_POINTS = 10
BAND = 4  # Width of a level band a spot is sorted into.
# Names for auto generated zones (grid cell of 4000 units -> area name), used to fill level holes.
CELL_NAMES = {
    (-2, 27): 'RuinsOfAgony', (-5, 35): 'RuinsOfDespair', (-4, 37): 'RuinsOfDespair',
    (-12, 11): 'Swampland', (-13, 11): 'Swampland', (-3, 12): 'DarkForest', (-4, 12): 'DarkForest',
    (3, 19): 'SpiderNest', (4, 19): 'SpiderNest', (-17, 26): 'WindmillHill', (-15, 27): 'AbandonedCamp',
    (-14, 27): 'AbandonedCamp', (36, -44): 'AbandonedCoalMines', (37, -43): 'AbandonedCoalMines',
}


def load_npc_levels(datapack):
    levels = {}
    for f in (datapack / 'stats' / 'npcs').rglob('*.xml'):
        text = f.read_text(encoding='utf-8', errors='replace')
        for m in re.finditer(r'<npc id="(\d+)" level="(\d+)" type="([A-Za-z]+)"', text):
            if m.group(3) in ('Monster', 'FeedableBeast', 'Chest', 'FriendlyMob'):
                levels[int(m.group(1))] = int(m.group(2))
    return levels


point_spawns = []


def load_spawns(datapack, levels):
    """Returns list of (x, y, level, radius) monster spawn blobs."""
    blobs = []
    for f in (datapack / 'spawns').rglob('*.xml'):
        text = f.read_text(encoding='utf-8', errors='replace')
        if 'enabled="false"' in text[:300]:
            continue
        for m in re.finditer(r'<npc id="(\d+)" x="(-?\d+)" y="(-?\d+)" z="(-?\d+)"', text):
            level = levels.get(int(m.group(1)))
            if level:
                blobs.append((int(m.group(2)), int(m.group(3)), level, 0))
                point_spawns.append((int(m.group(2)), int(m.group(3)), int(m.group(4)), level))
        for g in re.finditer(r'<spawn[^>]*>(.*?)</spawn>', text, re.S):
            body = g.group(1)
            nodes = [(int(a), int(b)) for a, b in re.findall(r'<node x="(-?\d+)" y="(-?\d+)"', body)]
            if not nodes:
                continue
            cx = sum(n[0] for n in nodes) / len(nodes)
            cy = sum(n[1] for n in nodes) / len(nodes)
            radius = max(math.hypot(n[0] - cx, n[1] - cy) for n in nodes)
            for npc_id, count in re.findall(r'<npc id="(\d+)"(?![^>]*\sx=)[^>]*?count="(\d+)"', body):
                level = levels.get(int(npc_id))
                if level:
                    for _ in range(min(int(count), 6)):
                        blobs.append((cx, cy, level, radius))
    return blobs


def near(blobs, x, y):
    out = []
    for bx, by, level, radius in blobs:
        if math.hypot(bx - x, by - y) <= RADIUS + radius:
            out.append(level)
    return out


def percentile(values, p):
    values = sorted(values)
    k = (len(values) - 1) * p
    lo = math.floor(k)
    hi = math.ceil(k)
    return values[lo] + (values[hi] - values[lo]) * (k - lo)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--apply', action='store_true')
    parser.add_argument('--datapack', default=str(ROOT / 'server' / 'game' / 'data'))
    args = parser.parse_args()
    datapack = Path(args.datapack)
    levels = load_npc_levels(datapack)
    blobs = load_spawns(datapack, levels)
    print(f'monster templates: {len(levels)}, spawn blobs: {len(blobs)}')
    text = XML.read_text(encoding='utf-8')
    new_text = text
    for zm in re.finditer(r'(<zone name="([^"]+)"([^>]*)minLevel="(\d+)" maxLevel="(\d+)">)(.*?)(</zone>)', text, re.S):
        header, name, extra, lo, hi, body, tail = zm.groups()
        lo = int(lo)
        hi = int(hi)
        points = re.findall(r'<point x="(-?\d+)" y="(-?\d+)" z="(-?\d+)" />', body)
        # Local danger of every point: the strongest packs around it (p90 of monster levels within RADIUS).
        groups = {}
        dropped = 0
        for x, y, z in points:
            around = near(blobs, int(x), int(y))
            if len(around) < 3:
                dropped += 1
                continue
            danger = int(percentile(around, 0.9))
            groups.setdefault((danger - 1) // BAND, []).append((x, y, z, danger))
        pieces = ''
        summary = []
        for band_index in sorted(groups):
            pts = groups[band_index]
            if len(pts) < MIN_POINTS:
                dropped += len(pts)
                continue
            dangers = [p[3] for p in pts]
            band_lo = max(1, min(dangers) - 3)
            band_hi = max(band_lo + 2, max(dangers))
            summary.append(f'{band_lo}-{band_hi}:{len(pts)}')
            pieces += f'\t<zone name="{name}"{extra}minLevel="{band_lo}" maxLevel="{band_hi}">\n' + ''.join(f'\t\t<point x="{p[0]}" y="{p[1]}" z="{p[2]}" />\n' for p in pts) + '\t</zone>\n'
        if not pieces:
            # Nothing safe here at all: keep the ten least dangerous points under the old band.
            ranked = sorted(((x, y, z, int(percentile(near(blobs, int(x), int(y)) or [hi], 0.9))) for x, y, z in points), key=lambda p: p[3])[:MIN_POINTS]
            pieces = f'\t<zone name="{name}"{extra}minLevel="{lo}" maxLevel="{hi}">\n' + ''.join(f'\t\t<point x="{p[0]}" y="{p[1]}" z="{p[2]}" />\n' for p in ranked) + '\t</zone>\n'
            summary.append(f'{lo}-{hi}:{len(ranked)} (least dangerous)')
        print(f'{name:22s} was {lo}-{hi}: {len(points)} points -> bands {", ".join(summary)}; dropped {dropped}')
        new_text = new_text.replace('\t' + header + body + tail + '\n', pieces)
    # Every level 1..76 must have a zone: fill holes with zones built from the spawns themselves.
    # Racial starting areas only serve their race, so the holes are computed over the shared zones.
    bands = [(int(a), int(b)) for a, b in re.findall(r'<zone name="[^"]+" minLevel="(\d+)" maxLevel="(\d+)">', new_text)]
    holes = [lvl for lvl in range(8, 77) if not any(a <= lvl <= b for a, b in bands)]
    if holes:
        print(f'levels without a shared zone: {holes} -> building zones from spawns')
        existing = set(re.findall(r'<zone name="([^"]+)"', new_text))
        grid = {}
        for bx, by, level, radius in blobs:
            if (min(holes) - 1) <= level <= (max(holes) + 2) and radius < 2500:
                grid.setdefault((int(bx // 4000), int(by // 4000)), []).append((bx, by, level))
        zones = {}
        for cell, entries in grid.items():
            if len(entries) < 12 or cell not in CELL_NAMES:
                continue
            zones.setdefault(CELL_NAMES[cell], []).extend(entries)
        added = ''
        for name, entries in zones.items():
            if name in existing:
                continue
            medians = sorted(e[2] for e in entries)
            lo = max(1, int(percentile(medians, 0.2)) - 2)
            hi = max(lo + 3, int(percentile(medians, 0.9)))
            # Points: spawn positions (spread), z from the nearest point spawn with a real z.
            points = []
            seen = set()
            for bx, by, level in entries:
                key = (int(bx // 300), int(by // 300))
                if key in seen:
                    continue
                seen.add(key)
                points.append((int(bx), int(by)))
            zpoints = []
            for px, py in points[:60]:
                best = None
                for x, y, z, lv in point_spawns:
                    d = math.hypot(x - px, y - py)
                    if best is None or d < best[0]:
                        best = (d, z)
                zpoints.append((px, py, best[1] if best and best[0] < 1500 else -3400))
            added += f'\t<zone name="{name}" minLevel="{lo}" maxLevel="{hi}">\n' + ''.join(f'\t\t<point x="{x}" y="{y}" z="{z}" />\n' for x, y, z in zpoints) + '\t</zone>\n'
            print(f'{name:22s} new zone {lo}-{hi} with {len(zpoints)} points')
        if added:
            new_text = new_text.replace('\t<town name=', added + '\t<town name=', 1)
        bands = [(int(a), int(b)) for a, b in re.findall(r'<zone name="[^"]+" minLevel="(\d+)" maxLevel="(\d+)">', new_text)]
        holes = [lvl for lvl in range(8, 77) if not any(a <= lvl <= b for a, b in bands)]
        if holes:
            print(f'still without a shared zone (nearest zone is used): {holes}')
    if args.apply:
        XML.write_text(new_text, encoding='utf-8')
        print(f'written {XML}')
    else:
        print('dry run, add --apply to write')


if __name__ == '__main__':
    main()
