"""Build the planner's sprite catalogue from YOUR local Project Zomboid install (Build 42).

What it reads (read-only):
  media/newtiledefinitions.tiles (+ patches)   tile properties: names, Facing, SpriteGridPos, ContainerCapacity
  media/scripts/generated/entities/            build recipes: sprite layouts of multi-tile player builds
  media/texturepacks/Tiles2x*.pack             the 2x sprite images (Tiles1x*.pack as fallback)
  media/models_X/vehicles, textures/Vehicles  the car models and paint, rendered to sprites (render_vehicles.py)
  media/models_X/Skinned, anims_X/Bob        the survivor walking and standing in 8 directions (render_character.py)

Usage:  python3 extract_game_textures.py [media folder] [--out folder]
  The media folder is found automatically for default Steam installs on macOS, Windows and Linux (or set PZ_MEDIA).
  Output goes to outputs/game-textures inside this project, or ./game-textures when run on its own.
  In Knox Planner, open Plan > Game textures and choose atlas.png and atlas.json from that folder.

What it writes (keep it private; it is The Indie Stone's artwork, so never upload or publish it):
  outputs/game-textures/atlas.png    every sprite the planner draws, 2x resolution, trimmed and packed
  outputs/game-textures/atlas.js     window.PZ_ATLAS = {scale, sprites, objects, info, sets}
  outputs/game-textures/atlas.json   the same data for tools
  outputs/game-textures/catalog-check.png   each object assembled in every facing, for visual checking

Objects are looked up by their in-game names (GroupName + CustomName) and assembled from the game's own
SpriteGridPos data, so a Pool Table or a Black Grand Piano is laid out exactly as the game lays it out.
"""
from pathlib import Path
import io, json, os, re, struct, sys

from PIL import Image, ImageDraw

ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / 'outputs' / 'game-textures' if (ROOT / 'work' / 'build_all.sh').exists() else Path.cwd() / 'game-textures'
STEAM_MEDIA = [
    Path.home() / 'Library/Application Support/Steam/steamapps/common/ProjectZomboid/Project Zomboid.app/Contents/Java/media',
    Path('C:/Program Files (x86)/Steam/steamapps/common/ProjectZomboid/media'),
    Path('C:/Program Files/Steam/steamapps/common/ProjectZomboid/media'),
    Path.home() / '.steam/steam/steamapps/common/ProjectZomboid/projectzomboid/media',
    Path.home() / '.local/share/Steam/steamapps/common/ProjectZomboid/projectzomboid/media',
]
DEFAULT_MEDIA = next((m for m in STEAM_MEDIA if (m / 'newtiledefinitions.tiles').exists()), STEAM_MEDIA[0])


def cli_args():
    """(media folder, output folder) from the command line: [media] [--out folder] [--verify]."""
    global OUT
    args = [a for a in sys.argv[1:] if a and a not in ('--verify', '--recipes-js')]
    if '--out' in args:
        i = args.index('--out'); OUT = Path(args[i + 1]).expanduser().resolve(); del args[i:i + 2]
    if '--media' in args:
        i = args.index('--media'); args[i:i + 2] = [args[i + 1]]
    return Path(args[0]).expanduser() if args else Path(os.environ.get('PZ_MEDIA', DEFAULT_MEDIA))

# ---------------------------------------------------------------- what the planner draws
# Construction sets: the game's four-piece wall sets (W face, N face, NW corner, SE post) plus door and window frames.
SETS = {
    'wall.wood2':  {'W': 'walls_exterior_wooden_01_40', 'N': 'walls_exterior_wooden_01_41', 'NW': 'walls_exterior_wooden_01_42', 'SE': 'walls_exterior_wooden_01_43',
                    'doorW': 'walls_exterior_wooden_01_50', 'doorN': 'walls_exterior_wooden_01_51', 'winW': 'walls_exterior_wooden_01_48', 'winN': 'walls_exterior_wooden_01_49'},
    'wall.wood3':  {'W': 'walls_exterior_wooden_01_24', 'N': 'walls_exterior_wooden_01_25', 'NW': 'walls_exterior_wooden_01_26', 'SE': 'walls_exterior_wooden_01_27',
                    'doorW': 'walls_exterior_wooden_01_34', 'doorN': 'walls_exterior_wooden_01_35', 'winW': 'walls_exterior_wooden_01_32', 'winN': 'walls_exterior_wooden_01_33'},
    'wall.brick2': {'W': 'walls_exterior_house_01_4', 'N': 'walls_exterior_house_01_5', 'NW': 'walls_exterior_house_01_6', 'SE': 'walls_exterior_house_01_7',
                    'doorW': 'walls_exterior_house_01_14', 'doorN': 'walls_exterior_house_01_15', 'winW': 'walls_exterior_house_01_12', 'winN': 'walls_exterior_house_01_13'},
    'wall.metal2': {'W': 'constructedobjects_01_48', 'N': 'constructedobjects_01_49', 'NW': 'constructedobjects_01_50', 'SE': 'constructedobjects_01_51',
                    'doorW': 'constructedobjects_01_58', 'doorN': 'constructedobjects_01_59', 'winW': 'constructedobjects_01_56', 'winN': 'constructedobjects_01_57'},
    'fence.wood2': {'W': 'carpentry_02_44', 'N': 'carpentry_02_45', 'NW': 'carpentry_02_46', 'SE': 'carpentry_02_47'},
    'fence.wood3': {'W': 'carpentry_02_48', 'N': 'carpentry_02_49', 'NW': 'carpentry_02_50', 'SE': 'carpentry_02_51'},
    'door.wood2':  {'W': 'carpentry_01_52', 'N': 'carpentry_01_53', 'Wopen': 'carpentry_01_54', 'Nopen': 'carpentry_01_55'},
    'window':      {'W': 'fixtures_windows_01_0', 'N': 'fixtures_windows_01_1'},
    'fencegate':   {'W': 'fixtures_doors_fences_01_4', 'N': 'fixtures_doors_fences_01_5'},
    'floor':       {'wood1': 'carpentry_02_58', 'wood2': 'carpentry_02_57', 'wood3': 'carpentry_02_56', 'gravel': 'blends_street_01_55',
                    'brick': 'floors_exterior_tilesandstone_01_6', 'metal': 'constructedobjects_01_86', 'dirt': 'blends_natural_01_64'},
    'ground':      {'grass': ['floors_exterior_natural_01_0', 'floors_exterior_natural_01_1'], 'asphalt': ['floors_exterior_street_01_16', 'floors_exterior_street_01_17'],
                    'concrete': ['floors_exterior_street_01_0', 'floors_exterior_street_01_1', 'floors_exterior_street_01_2'], 'railbed': ['floors_exterior_natural_01_14']},
    'rail':        {f'r{i}': f'industry_railroad_01_{i}' for i in range(0, 8)},
    'crops':       {f'c{i}': f'vegetation_farming_01_{i}' for i in (0, 1, 20, 21, 22, 44, 45, 46, 52, 53, 54, 61, 62, 69, 70, 77, 78)},
}

# Objects by in-game name: (label, tileset prefix or None, exact name). All facings are collected.
NAMED = [
    # storage and organisation
    ('Metal Crate', 'constructedobjects_01', 'Metal Crate'), ('Oakwood Shelves', 'furniture_shelving_01', 'Oakwood Shelves'),
    ('Large Metal Shelves', 'furniture_shelving_01', 'Large Metal Shelves'), ('Large Shop Shelves', 'location_shop_generic_01', 'Large Shop Shelves'),
    ('Military Locker', None, 'Military Locker'), ('Metal Locker', 'furniture_storage_02', 'Metal Locker'),
    ('Red Mobile Tool Cabinet', None, 'Red Mobile Tool Cabinet'), ('Grey File Cabinet', None, 'Grey File Cabinet'),
    ('Large Clothes Rack', None, 'Large Clothes Rack'), ('Coat Rack', None, 'Coat Rack'), ('Generic Shop Gun Shelves', None, 'Generic Shop Gun Shelves'),
    ('Magazine Stand', None, 'Magazine Stand'), ('Comics Shop Shelves', None, 'Comics Shop Shelves'), ('China Cabinet', None, 'China Cabinet'),
    ('Pallet With Bricks', None, 'Pallet With Bricks'), ('Empty Pallet', None, 'Empty Pallet'), ('Orange Barrel', None, 'Orange Barrel'),
    ('Big Cork Noteboard', None, 'Big Cork Noteboard'), ('Office Whiteboard', None, 'Office Whiteboard'),
    # workshop, forge, textiles, pottery
    ('Workbench', 'crafted_02', 'Workbench'), ('Large Oak Table', 'furniture_tables_high_01', 'Large Oak Table'), ('Blacksmith Anvil', 'crafted_01', 'Blacksmith Anvil'),
    ('Complex Loom', 'crafted_04', 'Complex Loom'), ('Spinning Wheel', 'crafted_04', 'Spinning Wheel'), ('Large Drying Rack', 'crafted_05', 'Large Drying Rack'),
    ('Tannin Barrel', 'crafted_05', 'Tannin Barrel'), ('Softening Beam', 'crafted_05', 'Softening Beam'), ('Pottery Table', 'crafted_01', 'Pottery Table'),
    ('Modern Pottery Wheel', 'crafted_01', 'Modern Pottery Wheel'),
    # kitchen and dining
    ('Oak Counter', 'fixtures_counters_01', 'Oak Counter'), ('Oak Corner Counter', 'fixtures_counters_01', 'Oak Corner Counter'),
    ('Modern Oven', 'appliances_cooking_01', 'Modern Oven'), ('White Fridge', 'appliances_refrigeration_01', 'White Fridge'),
    ('Chest Freezer', 'appliances_refrigeration_01', 'Chest Freezer'), ('White Sink', 'fixtures_sinks_01', 'White Sink'),
    ('Coffee X-press', 'appliances_cooking_01', 'Coffee X-press'), ('Extractor Hood', 'appliances_cooking_01', 'Extractor Hood'),
    ('Large Dark Wooden Table', 'furniture_tables_high_01', 'Large Dark Wooden Table'), ('Dark Wooden Chair', 'furniture_seating_indoor_02', 'Dark Wooden Chair'),
    ('Wooden Chair', 'furniture_seating_indoor_02', 'Wooden Chair'),
    # living, library, games, gym
    ('Green Comfy Couch', 'furniture_seating_indoor_01', 'Green Comfy Couch'), ('Green Comfy Chair', 'furniture_seating_indoor_01', 'Green Comfy Chair'),
    ('Brown Lazy Chair', 'furniture_seating_indoor_02', 'Brown Lazy Chair'), ('Brown Lazy Couch', 'furniture_seating_indoor_02', 'Brown Lazy Couch'),
    ('Long Fancy Low Table', 'furniture_tables_low_01', 'Long Fancy Low Table'), ('Brown Low Table', 'furniture_tables_low_01', 'Brown Low Table'),
    ('Premium Technologies Television', 'appliances_television_01', 'Premium Technologies Television'),
    ('Fancy Brown Rug', 'floors_rugs_01', 'Fancy Brown Rug'), ('Fancy Green Rug', 'floors_rugs_01', 'Fancy Green Rug'),
    ('Ficus', 'vegetation_indoor_01', 'Ficus'), ('Dragon Tree', 'vegetation_indoor_01', 'Dragon Tree'), ('Snake Plant', 'vegetation_indoor_01', 'Snake Plant'),
    ('Pool Table', 'recreational_01', 'Pool Table'), ('Pool Cue Stand', 'recreational_01', 'Pool Cue Stand'), ('Oldies Jukebox', 'recreational_01', 'Oldies Jukebox'),
    ('Dr. Oids Arcade Machine', 'recreational_01', 'Dr. Oids Arcade Machine'), ('Kaboom Arcade Machine', 'recreational_01', 'Kaboom Arcade Machine'),
    ('PAWS Pinball Machine', 'recreational_01', 'PAWS Pinball Machine'), ('Black Grand Piano', 'recreational_01', 'Black Grand Piano'),
    ('Fitness Contraption', 'recreational_sports_01', 'Fitness Contraption'), ('Human Hamster Wheel', 'recreational_sports_01', 'Human Hamster Wheel'),
    ('Sports Bench', 'furniture_seating_indoor_03', 'Sports Bench'), ('Mounted Deer Trophy', 'camping_01', 'Mounted Deer Trophy'),
    # bar
    ('Bar Counter', 'location_restaurant_bar_01', 'Bar Counter'), ('Bar Corner Counter', 'location_restaurant_bar_01', 'Bar Corner Counter'),
    ('Bar Stool', 'location_restaurant_bar_01', 'Bar Stool'),
    # wet core, laundry, medical, command
    ('White Washing Machine', 'appliances_laundry_01', 'White Washing Machine'), ('White Clothing Dryer', 'appliances_laundry_01', 'White Clothing Dryer'),
    ('Low Toilet', 'fixtures_bathroom_01', 'Low Toilet'), ('White Standing Sink', 'fixtures_sinks_01', 'White Standing Sink'),
    ('Deluxe Shower', 'fixtures_bathroom_01', 'Deluxe Shower'), ('Medicine Cabinet', 'fixtures_bathroom_01', 'Medicine Cabinet'),
    ('First Aid Cabinet', None, 'First Aid Cabinet'), ('Hospital Bed', 'furniture_bedding_01', 'Hospital Bed'),
    ('Premium Technologies Ham Radio', None, 'Premium Technologies Ham Radio'), ('Black Office Chair', 'furniture_seating_indoor_01', 'Black Office Chair'),
    # outdoors, farm, utilities
    ('Beach Chair', 'furniture_seating_outdoor_01', 'Beach Chair'), ('Fancy Outdoors Bench', 'furniture_seating_outdoor_01', 'Fancy Outdoors Bench'),
    ('Picknic Table', 'camping_01', 'Picknic Table'), ('Lamp on Pillar', 'carpentry_02', 'Lamp on Pillar'),
    ('Low Hedge', 'vegetation_ornamental_01', 'Low Hedge'), ('Bird Bath', 'vegetation_ornamental_01', 'Bird Bath'),
    ('Flowerbed', 'vegetation_ornamental_01', 'Flowerbed'), ('Ornamental Bush', 'vegetation_ornamental_01', 'Ornamental Bush'),
    ('Imitation Flamingo', 'vegetation_ornamental_01', 'Imitation Flamingo'), ('Scarecrow', 'location_farm_accesories_01', 'Scarecrow'),
    ('Animal Trough', 'location_farm_accesories_01', 'Animal Trough'), ('Salt Lick', 'location_farm_accesories_01', 'Salt Lick'),
    ('Red Generator', 'appliances_misc_01', 'Red Generator'), ('Rain Collector Barrel', 'carpentry_02', 'Rain Collector Barrel'),
    # v5: library, sorting bench, infirmary, workshop
    ('Quality Crafted Counter', 'carpentry_02', 'Quality Crafted Counter'), ('Wide Medical Cabinet', None, 'Wide Medical Cabinet'),
    ('Lectern Stand', None, 'Lectern Stand'), ('Globe Lamp', None, 'Globe Lamp'), ('Map of USA', None, 'Map of USA'),
    ('Small Clothes Rack', None, 'Small Clothes Rack'), ('Wood Pegboard', None, 'Wood Pegboard'), ('Lightwood Desk', None, 'Lightwood Desk'),
    ('Chalk Board', None, 'Chalk Board'),
]
# Objects that only exist as build recipes: layout from the recipe (row = grid y, names in a row = grid x).
ENTITIES = {'Chicken Hutch': 'ChickenHutch', 'Forge': 'Forge', 'Large Kiln': 'Kiln_Large', 'Smelting Furnace': 'Smelting_Furnace',
            'Grindstone': 'Grindstone', 'Butcher Hook': 'ButcherHook', 'Wooden Stairs': 'Wood_Stairs', 'Wooden Double Door': 'DoubleDoor',
            'Double Tall Metal Pole Gate': 'DoubleFenceGate', 'Composter': 'Composter', 'Wooden Crate': 'Wood_Crate_Lvl1',
            'Crate': 'Wood_Crate_Lvl2', 'Bookcase': 'Wood_Bookcase_Lvl2', 'Low Bookcase': 'Wood_BookcaseSmall_Lvl2',
            'Crafted Wall Shelves': 'Wood_Shelves_Lvl2', 'Fancy Metal Crate': 'Metal_Crate_Lvl2', 'Crafted Table': 'Wood_TableLvl3',
            'Crafted Chair': 'Wood_Chair_Lvl3', 'Crafted Desk': 'Wood_TableDrawerLvl3', 'Wooden Sign': 'WoodSign', 'Metal Wall Locker': 'Metal_LockerSmall_Lvl2'}
# Explicit extras
EXPLICIT = {
    'Crate Stack 2': {'S': [[0, 0, 'carpentry_01_16'], [0, 0, 'carpentry_01_17']]},
    'Crate Stack 3': {'S': [[0, 0, 'carpentry_01_16'], [0, 0, 'carpentry_01_17'], [0, 0, 'carpentry_01_18']]},
    'Floor Lamp': {'S': [[0, 0, 'lighting_indoor_01_57']]},
    'Military Crate Stack 3': {'S': [[0, 0, 'location_military_generic_01_0'], [0, 0, 'location_military_generic_01_2'], [0, 0, 'location_military_generic_01_4']]},
    'Curtain': {'E': [[0, 0, 'fixtures_windows_curtains_01_0']], 'W': [[0, 0, 'fixtures_windows_curtains_01_1']],
                'S': [[0, 0, 'fixtures_windows_curtains_01_2']], 'N': [[0, 0, 'fixtures_windows_curtains_01_3']]},
}


# Repairs. The game's own data places these pieces wrongly; each was confirmed by seam-matching the assembled
# sprites (python3 work/extract_game_textures.py --verify) and by eye on catalog-check.png.
#  * Floor rugs (MoveType FloorRug) store SpriteGridPos as row,column: transpose them.
#  * Black Grand Piano: the N and W groups have their Facing labels swapped and scrambled grid positions.
OVERRIDES = {
    'Black Grand Piano': {
        'N': [[0, 2, 'recreational_01_104'], [1, 2, 'recreational_01_105'], [0, 1, 'recreational_01_106'], [1, 1, 'recreational_01_107'], [0, 0, 'recreational_01_108'], [1, 0, 'recreational_01_109']],
        'W': [[0, 0, 'recreational_01_96'], [1, 0, 'recreational_01_97'], [2, 0, 'recreational_01_98'], [0, 1, 'recreational_01_99'], [1, 1, 'recreational_01_100'], [2, 1, 'recreational_01_101']],
    },
}


# ---------------------------------------------------------------- readers
def rd_i(f):
    return struct.unpack('<i', f.read(4))[0]


def rd_s(f):
    return f.read(rd_i(f)).decode('latin1')


def tile_defs(media):
    db = {}
    for fn in ['newtiledefinitions.tiles', 'tiledefinitions_erosion.tiles', 'tiledefinitions_noiseworks.patch.tiles', 'tiledefinitions_overlays.tiles']:
        p = media / fn
        if not p.exists():
            continue
        d = p.read_bytes(); i = 4
        assert d[:4] == b'tdef', fn

        def i32():
            nonlocal i; v = struct.unpack_from('<i', d, i)[0]; i += 4; return v

        def line():
            nonlocal i; j = d.index(b'\n', i); s = d[i:j].decode('latin1'); i = j + 1; return s
        i32()
        for _ in range(i32()):
            name = line(); line(); i32(); i32(); i32(); n = i32()
            for t in range(n):
                props = {}
                for _ in range(i32()):
                    k = line(); props[k] = line()
                if props:
                    db.setdefault(f'{name}_{t}', {}).update(props)     # patches add to entries
    return db


# ---------------------------------------------------------------- build materials
# The studio's material ids, mapped to the game's own build recipes (entity names in media/scripts/generated/entities).
# Sprites and costs are read from those scripts, so they follow whatever game version is installed.
BUILD = {
    'walls': {   # id: (wall, door frame, window frame, frame it is built on or None)
        'wood1': ('WoodenWallLvl1', 'WoodDoorFrameLvl1', 'WoodenWindowFrameLvl1', 'WoodenWallFrame'),
        'wood2': ('WoodenWallLvl2', 'WoodDoorFrameLvl2', 'WoodenWindowFrameLvl2', 'WoodenWallFrame'),
        'wood3': ('WoodenWallLvl3', 'WoodDoorFrameLvl3', 'WoodenWindowFrameLvl3', 'WoodenWallFrame'),
        'log': ('LogWall', 'LogDoorFrameLvl1', 'LogWindowFrameLvl1', None),
        'stone': ('StoneWall', 'StoneDoorFrame', 'StoneWindowFrame', None),
        'brick1': ('BrickWallLvl1', 'BrickDoorFrameLvl1', 'BrickWindowFrameLvl1', None),
        'brick2': ('BrickWallLvl2', 'BrickDoorFrameLvl2', 'BrickWindowFrameLvl2', None),
        'metal1': ('MetalWallLvl1', 'MetalDoorFrameLvl1', 'MetalWindowFrameLvl1', 'MetalWallFrame'),
        'metal2': ('MetalWallLvl2', 'MetalDoorFrameLvl2', 'MetalWindowFrameLvl2', 'MetalWallFrame'),
    },
    'rails': {'wood1': 'WoodFenceLvl1', 'wood2': 'WoodFenceLvl2', 'wood3': 'WoodFenceLvl3', 'log': 'LogFence', 'stick': 'StickFence',
              'brick': 'BrickFenceLvl2', 'metal1': 'MetalFenceLvl1', 'metal2': 'MetalFenceLvl2', 'pole': 'MetalSmallPoleFence',
              'wire': 'MetalSmallWireFence', 'tallmetal': 'MetalBigMetalFence', 'tallwire': 'MetalBigWireFence'},
    'doors': {'wood1': 'WoodenDoorLvl1', 'wood2': 'WoodenDoorLvl2', 'wood3': 'WoodenDoorLvl3', 'metal1': 'MetalDoorLvl1', 'metal2': 'MetalDoorLvl2'},
    'gates': {'wood': 'DoubleDoor', 'metal': 'DoubleFenceGate'},
    'floors': {'wood1': 'WoodFloorLvl1', 'wood2': 'WoodFloorLvl2', 'wood3': 'WoodFloorLvl3', 'brick': 'BrickFloorLvl1', 'metal': 'MetalFloorLvl1',
               'gravel': 'GravelFloor', 'dirt': 'DirtFloor', 'sand': 'SandFloor'},
    'stairs': {'wood': 'Wood_Stairs', 'log': 'Log_Stairs', 'metal': 'Metal_Stairs'},
}


def build_recipes(media):
    """Every craftable build entity: {name: {skill, hp, prev, in: [[count, item]], tools, faces: {face: [sprites]}}}."""
    out = {}
    root = media / 'scripts' / 'generated' / 'entities'
    for dirpath, _, files in os.walk(root):
        for fn in files:
            t = open(os.path.join(dirpath, fn), errors='ignore').read()
            for m in re.finditer(r'entity (\w+)\s*\{(.*?)\n    \}', t, re.S):
                body = m.group(2)
                rec = re.search(r'component CraftRecipe\s*\{(.*?)\n        \}', body, re.S)
                if not rec:
                    continue
                r = rec.group(1)
                ins, tools = [], []
                block = re.search(r'inputs\s*\{(.*?)\n            \}', r, re.S)
                for line in (block.group(1).splitlines() if block else []):
                    mm = re.match(r'\s*item (\d+) (?:\[([^\]]+)\]|tags\[([^\]]+)\])(.*)', line)
                    if not mm:
                        continue
                    item = mm.group(2) or 'tag:' + mm.group(3)
                    (tools if 'mode:keep' in mm.group(4) else ins).append(item if 'mode:keep' in mm.group(4) else [int(mm.group(1)), item])
                faces = {}
                for f in re.finditer(r'face (\w+)\s*\{\s*layer\s*\{(.*?)\}', body, re.S):
                    faces[f.group(1)] = [n for row in re.findall(r'row = ([^,\n]+)', f.group(2)) for n in row.split() if n != 'false']
                skill = re.search(r'SkillRequired = ([^,\n]+)', r)
                hp = re.search(r'\bhealth = (\d+)', body)
                prev = re.search(r'previousStage = ([^,\n]+)', body)
                out[m.group(1)] = {'skill': skill.group(1).strip() if skill else None, 'hp': int(hp.group(1)) if hp else None,
                                   'prev': prev.group(1).strip().split(';') if prev else [], 'in': ins, 'tools': tools, 'faces': faces}
    return out


def object_recipes(objects, recipes):
    """Catalogue object -> the build recipe that makes it, matched by the sprites both use."""
    by_sprite = {}
    for e, r in recipes.items():
        for names in r['faces'].values():
            for n in names:
                by_sprite.setdefault(n, set()).add(e)
    out = {}
    for o, faces in objects.items():
        ents = set()
        for cells in faces.values():
            for c in cells:
                ents |= by_sprite.get(c[2], set())
        if ents:
            out[o] = sorted(ents)[0]
    return out


def object_sizes(objects, info):
    """Name -> [{facing: [w, h]}, capacity]: what the planner's AI build kit tells an assistant about each object. Game data only."""
    out = {}
    for n, faces in objects.items():
        if n == 'Curtain':
            continue
        sz = {f: [max(c[0] for c in cells) + 1, max(c[1] for c in cells) + 1] for f, cells in faces.items()}
        cap = (info.get(n) or {}).get('ContainerCapacity')
        out[n] = [sz, int(cap) if cap else 0]
    return out


def write_recipes_js(recipes, obj_recipe, keep, sizes=None):
    import datetime
    lean = {k: {kk: v[kk] for kk in ('skill', 'hp', 'prev', 'in', 'tools')} for k, v in recipes.items() if k in keep or k in obj_recipe.values()}
    dest = Path(__file__).resolve().parent / 'studio_recipes.js'
    dest.write_text('/* Build 42 build recipes read from the game\'s scripts by extract_game_textures.py --recipes-js on '
                    f'{datetime.date.today()}. Game data only (no artwork). An atlas extracted from a player\'s own game carries\n'
                    '   that version\'s recipes, which take precedence over these defaults. */\n'
                    f'(function (root) {{ const R = {json.dumps(lean, separators=(",", ":"))};\n'
                    f'const O = {json.dumps(obj_recipe, separators=(",", ":"))};\n'
                    f'const S = {json.dumps(sizes or {}, separators=(",", ":"))};\n'
                    "if (typeof module === 'object' && module.exports) module.exports = { recipes: R, objRecipe: O, sizes: S }; else root.STUDIO_RECIPES = { recipes: R, objRecipe: O, sizes: S };\n"
                    '})(typeof window !== \'undefined\' ? window : globalThis);\n')
    print('recipes ->', dest)


def build_sets(db, recipes):
    """Sprites for each build material: walls and railings as the game's four-piece sets (W, N, NW corner, SE post)."""
    corner = {}
    for name, props in db.items():
        if 'CornerWestWall' in props:
            corner.setdefault(props['CornerWestWall'], []).append((name, props.get('CornerNorthWall')))
    def four(entity):
        f = recipes.get(entity, {}).get('faces', {})
        if not f.get('W') or not f.get('N'):
            return None
        w, n = f['W'][0], f['N'][0]
        nw = next((c for c, cn in corner.get(w, []) if cn == n), None) or next((c for c, _ in corner.get(w, [])), None)
        se = None
        if nw:
            base, _, idx = nw.rpartition('_')
            cand = f'{base}_{int(idx) + 1}'
            se = cand if 'WallSE' in db.get(cand, {}) else None
        return {'W': w, 'N': n, 'NW': nw, 'SE': se}
    def pair(entity):
        f = recipes.get(entity, {}).get('faces', {})
        return {'W': f['W'][0], 'N': f['N'][0]} if f.get('W') and f.get('N') else None
    out = {'walls': {}, 'rails': {}, 'doors': {}, 'floors': {}}
    for mid, (wall, door, win, _) in BUILD['walls'].items():
        s, d, w = four(wall), pair(door), pair(win)
        if s and d and w:
            out['walls'][mid] = {**s, 'doorW': d['W'], 'doorN': d['N'], 'winW': w['W'], 'winN': w['N']}
    for mid, ent in BUILD['rails'].items():
        s = four(ent)
        if s:
            out['rails'][mid] = s
    for mid, ent in BUILD['doors'].items():
        s = pair(ent)
        if s:                            # the game's door tiles: closed W, closed N, then open W, open N
            base, _, idx = s['W'].rpartition('_')
            for k, dk in (('Wopen', 2), ('Nopen', 3)):
                if f'{base}_{int(idx) + dk}' in db:
                    s[k] = f'{base}_{int(idx) + dk}'
            out['doors'][mid] = s
    for mid, ent in BUILD['floors'].items():
        f = recipes.get(ent, {}).get('faces', {})
        spr = (f.get('SINGLE') or f.get('W') or [None])[0]
        if spr:
            out['floors'][mid] = spr
    return out


def entity_layouts(media):
    out = {}
    root = media / 'scripts' / 'generated' / 'entities'
    for dirpath, _, files in os.walk(root):
        for fn in files:
            t = open(os.path.join(dirpath, fn), errors='ignore').read()
            for m in re.finditer(r'entity (\w+)\s*\{(.*?)\n    \}', t, re.S):
                faces = {}
                for f in re.finditer(r'face (\w+)\s*\{(.*?)\n            \}', m.group(2), re.S):
                    layer = re.search(r'layer\s*\{(.*?)\}', f.group(2), re.S)
                    if not layer:
                        continue
                    rows = [r.strip() for r in re.findall(r'row = ([^,\n]+)', layer.group(1))]
                    cells = []
                    for gy, row in enumerate(rows):
                        for gx, name in enumerate(row.split()):
                            if name != 'false':
                                cells.append([gx, gy, name])
                    if cells:
                        faces[f.group(1)] = cells
                if faces:
                    out[m.group(1)] = faces
    return out


def pages(path):
    with open(path, 'rb') as f:
        assert f.read(4) == b'PZPK'
        rd_i(f)
        for _ in range(rd_i(f)):
            rd_s(f); count = rd_i(f); rd_i(f)
            ents = [(rd_s(f),) + struct.unpack('<8i', f.read(32)) for _ in range(count)]
            size = rd_i(f)
            yield ents, f, size


def load_images(media, names):
    """2x images (frame 128 x 256); missing ones from 1x, doubled."""
    found = {}
    for pack, scale in [('Tiles2x.floor.pack', 1), ('Tiles2x.pack', 1), ('Tiles1x.floor.pack', 2), ('Tiles1x.pack', 2)]:
        todo = names - set(found)
        if not todo:
            break
        p = media / 'texturepacks' / pack
        if not p.exists():
            continue
        for ents, f, size in pages(p):
            sel = [e for e in ents if e[0] in todo and e[0] not in found]
            if not sel:
                f.seek(size, 1); continue
            page = Image.open(io.BytesIO(f.read(size))).convert('RGBA')
            for name, x, y, w, h, ox, oy, fw, fh in sel:
                im = page.crop((x, y, x + w, y + h))
                if scale != 1:
                    im = im.resize((w * scale, h * scale), Image.NEAREST)
                found[name] = (im, ox * scale, oy * scale)
    return found


# ---------------------------------------------------------------- catalogue
def object_info(db, names):
    """What the planner needs to know about an object: container capacity, surface height, table-top / wall flags."""
    out = {}
    for n in names:
        p = db.get(n, {})
        if 'ContainerCapacity' in p:
            out['ContainerCapacity'] = max(int(p['ContainerCapacity']), int(out.get('ContainerCapacity', 0)))
        for k in ('container', 'Surface'):
            if k in p and k not in out:
                out[k] = p[k]
        for k in ('IsTableTop', 'IsHigh'):
            if k in p:
                out[k] = True
    return out


def build_catalogue(db, ents):
    objects, info, missing = {}, {}, []
    by_name = {}
    for n, p in db.items():
        nm = (p.get('GroupName', '') + ' ' + p.get('CustomName', '')).strip()
        for key in {nm, p.get('CustomName', '')}:
            if key:
                by_name.setdefault(key, []).append(n)
    idx = lambda n: int(n.rsplit('_', 1)[1])
    for label, prefix, name in NAMED:
        cands = [n for n in by_name.get(name, []) if prefix is None or n.rsplit('_', 1)[0] == prefix]
        if prefix is None and cands:            # keep one tileset when a name is used by several
            fam = sorted({n.rsplit('_', 1)[0] for n in cands})[0]
            cands = [n for n in cands if n.rsplit('_', 1)[0] == fam]
        if not cands:
            missing.append(label); continue
        faces = {}
        for n in sorted(cands, key=idx):
            p = db[n]
            face = p.get('Facing', 'S')
            gx, gy = (int(v) for v in p.get('SpriteGridPos', '0,0').split(','))
            if p.get('MoveType') == 'FloorRug':
                gx, gy = gy, gx
            cells = faces.setdefault(face, [])
            if any(c[0] == gx and c[1] == gy for c in cells):
                continue                          # first variant per facing and cell
            cells.append([gx, gy, n])
        objects[label] = faces
        info[label] = object_info(db, cands)
    for label, ent in ENTITIES.items():
        if ent in ents:
            objects[label] = ents[ent]
            info[label] = object_info(db, [c[2] for cells in ents[ent].values() for c in cells])
        else:
            missing.append(label)
    objects.update(EXPLICIT)
    for label, faces in OVERRIDES.items():
        objects.setdefault(label, {}).update(faces)
    info['Crate Stack 3'] = {'ContainerCapacity': db.get('carpentry_01_16', {}).get('ContainerCapacity', '60'), 'stack': 3}
    info['Crate Stack 2'] = {'ContainerCapacity': db.get('carpentry_01_16', {}).get('ContainerCapacity', '60'), 'stack': 2}
    return objects, info, missing


def main():
    media = cli_args()
    if not (media / 'newtiledefinitions.tiles').exists():
        raise SystemExit(f'Project Zomboid media folder not found: {media}\nPass the game\'s media folder as the first argument.')
    db = tile_defs(media)
    ents = entity_layouts(media)
    objects, info, missing = build_catalogue(db, ents)
    recipes = build_recipes(media)
    bsets = build_sets(db, recipes)
    names = set()
    for group in bsets.values():
        for v in group.values():
            names.update([v] if isinstance(v, str) else [x for x in v.values() if x])
    for s in SETS.values():
        for v in s.values():
            names.update(v if isinstance(v, list) else [v])
    for faces in objects.values():
        for cells in faces.values():
            names.update(c[2] for c in cells)
    imgs = load_images(media, names)
    lost = sorted(names - set(imgs))
    # cars are 3D models in the game: render the real models and paint shells into sprites (render_vehicles.py)
    try:
        import render_vehicles
        car_imgs, vehicles = render_vehicles.render_all(media)
        imgs.update(car_imgs)
    except Exception as e:                       # never block the rest of the atlas on the car renderer
        print('vehicles not rendered:', e); vehicles = []
    # the survivor for Explore mode: the game's body model, dressed and animated, in 8 directions (render_character.py)
    try:
        import render_character
        char_imgs, character = render_character.render_all(media)
        imgs.update(char_imgs)
    except Exception as e:
        print('character not rendered:', e); character = None
    # The game's wall cutaway masks (media/wallcutaways.png, read by IsoGridSquare.DoCutawayShader): 66 x 226 cells at fixed
    # x positions, rows for plain walls (0), window frames (226) and door frames (904). A cell lines up with the wall face in
    # the 128 x 256 sprite frame at x 0 (west faces) or 62 (north faces). Opaque = the part of the wall kept.
    #   west:  256 post at the south end, 512 base strip only, 768 post at the north end, 896 whole wall
    #   north: 444 post at the east end, 700 base strip only, 956 post at the west end, 828 whole wall
    try:
        cut = Image.open(media / 'wallcutaways.png').convert('RGBA')
        for row in (0, 226, 904):
            for X, ox in ((256, 0), (512, 0), (768, 0), (896, 0), (444, 62), (700, 62), (956, 62), (828, 62)):
                imgs[f'cut_{row}_{X}'] = (cut.crop((X, row, X + 66, row + 226)), ox, 0)
    except FileNotFoundError:
        pass
    # pack
    order = sorted(imgs, key=lambda n: -imgs[n][0].height)
    W, x, y, row_h, placed = 2048, 0, 0, 0, {}
    for n in order:
        im = imgs[n][0]
        if x + im.width > W:
            x, y, row_h = 0, y + row_h + 2, 0
        placed[n] = (x, y); x += im.width + 2; row_h = max(row_h, im.height)
    atlas = Image.new('RGBA', (W, y + row_h + 2), (0, 0, 0, 0))
    sprites, masks = {}, {}
    import base64
    for n, (ax, ay) in placed.items():
        im, ox, oy = imgs[n]
        atlas.paste(im, (ax, ay)); sprites[n] = [ax, ay, im.width, im.height, ox, oy]
        # hit mask for picking in the planner: one bit per 8 x 8 block of the sprite that has visible pixels
        a = im.getchannel('A'); gw, gh = (im.width + 7) // 8, (im.height + 7) // 8
        small = a.resize((gw, gh), Image.BOX)
        bits = bytearray((gw * gh + 7) // 8)
        for i, v in enumerate(small.getdata()):
            if v > 40: bits[i >> 3] |= 1 << (i & 7)
        masks[n] = [gw, base64.b64encode(bytes(bits)).decode()]
    OUT.mkdir(parents=True, exist_ok=True)
    atlas.save(OUT / 'atlas.png', optimize=True)
    # recipes for the materials calculator: build entities only (their costs, skills, health and upgrade chain)
    keep = {e for g in BUILD.values() for v in g.values() for e in (v if isinstance(v, tuple) else (v,)) if e}
    spr_objects = {c[2] for faces in objects.values() for cells in faces.values() for c in cells}
    rec_out = {k: v for k, v in recipes.items() if k in keep or any(n in spr_objects for f in v['faces'].values() for n in f)}
    obj_recipe = object_recipes(objects, rec_out)
    if '--recipes-js' in sys.argv:                  # refresh the app's built-in defaults (game data only: no artwork)
        write_recipes_js(rec_out, obj_recipe, keep, object_sizes(objects, info))
    build = {'sets': bsets, 'materials': {g: {k: list(v) if isinstance(v, tuple) else v for k, v in m.items()} for g, m in BUILD.items()}}
    data = {'scale': 2, 'frame': [128, 256], 'sprites': sprites, 'objects': objects, 'info': info, 'sets': SETS, 'vehicles': vehicles, 'masks': masks,
            'build': build, 'recipes': rec_out, 'objRecipe': obj_recipe, 'character': character}
    js = json.dumps(data, separators=(',', ':'))
    (OUT / 'atlas.json').write_text(js)
    (OUT / 'atlas.js').write_text('window.PZ_ATLAS=' + js + ';')
    (OUT / 'README.txt').write_text('Sprites and object data extracted from your own Project Zomboid install by work/extract_game_textures.py.\n'
                                    'They are The Indie Stone\'s artwork: keep them for this personal planner and do not publish or share this folder.\n')
    check_sheet(objects, imgs, OUT / 'catalog-check.png')
    print(f'{len(sprites)} sprites, {len(objects)} objects, {len(vehicles)} vehicles, {len(rec_out)} recipes, '
          f'{", ".join(f"{len(v)} {k}" for k, v in bsets.items())} -> {OUT / "atlas.png"} ({atlas.width} x {atlas.height})')
    if missing:
        print('objects not found:', ', '.join(missing))
    if lost:
        print('sprites without images:', ', '.join(lost[:40]))


def check_sheet(objects, imgs, dest):
    """Every object assembled in every facing on a small wooden floor, drawn the way the planner draws it."""
    cell_w, cell_h, cols = 420, 330, 6
    entries = [(label, face, cells) for label, faces in objects.items() for face, cells in faces.items()]
    sheet = Image.new('RGBA', (cols * cell_w, ((len(entries) + cols - 1) // cols) * cell_h), (34, 38, 34, 255))
    d = ImageDraw.Draw(sheet)
    for k, (label, face, cells) in enumerate(entries):
        cx, cy = (k % cols) * cell_w + cell_w // 2, (k // cols) * cell_h + 150

        def put(tx, ty, name):
            if name not in imgs:
                return
            im, ox, oy = imgs[name]
            sx, sy = cx + (tx - ty) * 64 - 64 + ox, cy + (tx + ty) * 32 - 192 + oy
            sheet.alpha_composite(im, (int(sx), int(sy)))
        for tx in range(-1, 3):
            for ty in range(-1, 3):
                put(tx, ty, 'carpentry_02_57')
        for gx, gy, name in sorted(cells, key=lambda c: (c[0] + c[1], c[0])):
            put(gx, gy, name)
        d.text(((k % cols) * cell_w + 6, (k // cols) * cell_h + cell_h - 22), f'{label} [{face}]  {len(cells)} tile(s)', fill=(255, 225, 90, 255))
    sheet.save(dest)


def verify_layouts(objects, imgs, only=None):
    """Seam-match every multi-tile object: try every assignment of its sprites to its grid (and the transposed grid)
    and report objects whose own layout scores clearly worse than the best one. Candidates only: flat-topped furniture
    can score badly while correct (the Lightwood Desk does), so confirm each one on catalog-check.png before adding an
    override. Slow (a few minutes); run with --verify."""
    import itertools
    import numpy as np
    arrs = {n: (np.asarray(im.convert('RGBA')).astype(np.float32), ox, oy) for n, (im, ox, oy) in imgs.items()}

    def score(cells, size=1100):
        rgb = np.zeros((size, size, 3), np.float32); A = np.zeros((size, size), bool); O = np.full((size, size), -1, np.int16); overlap = 0
        for k, (gx, gy, n) in enumerate(sorted(cells, key=lambda c: (c[0] + c[1], c[0]))):
            a, ox, oy = arrs[n]
            x = size // 2 + (gx - gy) * 64 - 64 + ox; y = 300 + (gx + gy) * 32 - 192 + oy
            h, w = a.shape[:2]; m = a[:, :, 3] > 128
            sA = A[y:y + h, x:x + w]; overlap += int(np.count_nonzero(m & sA))
            rgb[y:y + h, x:x + w][m] = a[:, :, :3][m]; sA[m] = True; O[y:y + h, x:x + w][m] = k
        per = np.count_nonzero(A[:, 1:] != A[:, :-1]) + np.count_nonzero(A[1:, :] != A[:-1, :])
        seam = 0.0
        for s1, s2 in (((slice(None), slice(1, None)), (slice(None), slice(None, -1))), ((slice(1, None), slice(None)), (slice(None, -1), slice(None)))):
            both = A[s1] & A[s2] & (O[s1] != O[s2])
            if both.any():
                seam += float((np.abs(rgb[s1][both] - rgb[s2][both]).mean(axis=1) / 255.0).sum())
        return seam + per * .5 + overlap

    report = []
    for label, faces in objects.items():
        if (only and label not in only) or label in ENTITIES or label in EXPLICIT:
            continue          # build recipes and explicit stacks are authoritative
        for face, cells in faces.items():
            pos = {(c[0], c[1]) for c in cells}
            if not 2 <= len(cells) <= 6 or len(pos) != len(cells) or any(c[2] not in arrs for c in cells):
                continue
            base = score(cells); best = (base, cells)
            names = [c[2] for c in cells]
            for posset in {tuple(sorted(pos)), tuple(sorted((y, x) for x, y in pos))}:
                for perm in itertools.permutations(posset):
                    cand = [[p[0], p[1], n] for p, n in zip(perm, names)]
                    v = score(cand)
                    if v < best[0]:
                        best = (v, cand)
            if best[0] < base * .6:
                report.append((label, face, round(base), round(best[0]), best[1]))
    return report


if __name__ == '__main__':
    if '--verify' in sys.argv:
        media = cli_args()
        objs, _, _ = build_catalogue(tile_defs(media), entity_layouts(media))
        need = {c[2] for f in objs.values() for cells in f.values() for c in cells}
        bad = verify_layouts(objs, load_images(media, need))
        print('\n'.join(f'check by eye: {l} [{f}] data {a} vs best {b}: {c}' for l, f, a, b, c in bad) or 'every multi-tile object assembles cleanly')
    else:
        main()
