"""Zip introspection helpers for category-specific version metadata.

Parsed once at upload time and stored on the Version row, so the detail page
never re-reads large archives. All functions take the on-disk path of an
adopted file (under MEDIA_ROOT) and return plain dicts/values.
"""

import io
import json
import os
import re
import struct
import zipfile

INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'}
PROBLEM_TAGS = {'broken', 'bugged', 'borked'}

# Blockbench-compatible model formats accepted by the explicit Model category.
MODEL_FILE_EXTENSIONS = ('.bbmodel', '.geo.json', '.jem', '.gltf', '.glb')


def _png_size(head):
    """(width, height) from a PNG IHDR header, or None when not a PNG."""
    if len(head) < 24 or head[:8] != b'\x89PNG\r\n\x1a\n':
        return None
    return (struct.unpack('>I', head[16:20])[0], struct.unpack('>I', head[20:24])[0])


SKIN_SIZES = {(64, 32), (64, 64), (128, 128)}


def read_skins_manifest(path):
    """Scan the archive for Minecraft skin PNGs (64x32, 64x64 or 128x128).

    Returns None when no skins are found; otherwise {skins: [{name, member,
    width, height}]} where ``member`` is the zip member served via pack_asset.
    """
    zf = _open_zip(path)
    if zf is None:
        return None
    try:
        skins = []
        for name in sorted(n for n in set(zf.namelist()) if n.lower().endswith('.png')):
            try:
                with zf.open(name) as fh:
                    head = fh.read(24)
            except (KeyError, OSError, zipfile.BadZipFile):
                continue
            size = _png_size(head)
            if size and size in SKIN_SIZES:
                skins.append({
                    'name': name.rsplit('/', 1)[-1],
                    'member': name,
                    'width': size[0],
                    'height': size[1],
                })
        if not skins:
            return None
        return {'skins': skins}
    finally:
        zf.close()


def _open_zip_any(source):
    """Open a zip from a path or raw bytes."""
    if isinstance(source, (bytes, bytearray)):
        return zipfile.ZipFile(io.BytesIO(source))
    return _open_zip(source)


def _mc_from_filename(filename):
    """Best-effort MC version from a mod filename like 'x-1.0+mc26.2.jar'."""
    m = re.search(r'mc(\d+(?:\.\d+)*)', str(filename).lower())
    return m.group(1) if m else ''


def _manifest_value(raw, key):
    for line in raw.splitlines():
        if line.startswith(key + ':'):
            return line.split(':', 1)[1].strip()
    return ''


def read_mod_manifest(source):
    """Parse a single Minecraft mod jar (path or bytes) into metadata or None.

    Loader detection order: fabric.mod.json → quilt.mod.json →
    META-INF/neoforge.mods.toml → META-INF/mods.toml → META-INF/MANIFEST.MF.
    """
    zf = _open_zip_any(source)
    if zf is None:
        return None
    try:
        names = set(zf.namelist())
        if 'fabric.mod.json' in names:
            data = _decode(zf.read('fabric.mod.json'))
            depends = data.get('depends') or {}
            deps = [
                {'modId': k, 'type': 'required', 'versionRange': v}
                for k, v in depends.items() if k and v
            ]
            return {
                'id': data.get('id') or '',
                'name': data.get('name') or data.get('id') or '',
                'version': data.get('version') or '',
                'modloader': 'fabric',
                'description': data.get('description') or '',
                'environment': data.get('environment') or '',
                'minecraft': depends.get('minecraft') or '',
                'dependencies': deps,
            }
        if 'quilt.mod.json' in names:
            data = _decode(zf.read('quilt.mod.json'))
            ql = data.get('quilt_loader') or {}
            meta = ql.get('metadata') or {}
            deps = []
            for d in ql.get('depends') or []:
                deps.append({
                    'modId': d.get('id') or '',
                    'type': d.get('reason') or 'required',
                    'versionRange': d.get('versions') or '',
                })
            minecraft = next((d['versionRange'] for d in deps if d['modId'] == 'minecraft'), '')
            return {
                'id': ql.get('id') or '',
                'name': meta.get('name') or ql.get('id') or '',
                'version': ql.get('version') or '',
                'modloader': 'quilt',
                'description': meta.get('description') or '',
                'environment': '',
                'minecraft': minecraft,
                'dependencies': deps,
            }
        for member, loader in (('META-INF/neoforge.mods.toml', 'neoforge'), ('META-INF/mods.toml', 'forge')):
            if member in names:
                parsed = _parse_mods_toml(zf.read(member).decode('utf-8', 'replace'), loader)
                if parsed:
                    return parsed
        if 'META-INF/MANIFEST.MF' in names:
            raw = zf.read('META-INF/MANIFEST.MF').decode('utf-8', 'replace')
            title = _manifest_value(raw, 'Implementation-Title')
            version = _manifest_value(raw, 'Implementation-Version')
            if title or version:
                return {
                    'id': title or '',
                    'name': title or 'unknown',
                    'version': version or '',
                    'modloader': 'unknown',
                    'description': '',
                    'environment': '',
                    'minecraft': '',
                    'dependencies': [],
                }
        return None
    finally:
        zf.close()


def _parse_mods_toml(text, loader):
    """Parse a Forge/NeoForge mods.toml / neoforge.mods.toml."""
    try:
        import tomllib
        data = tomllib.loads(text)
    except Exception:
        return None
    mods = data.get('mods') or []
    if not mods:
        return None
    m = mods[0]
    mod_id = m.get('modId') or ''
    deps = []
    for d in (data.get('dependencies') or {}).get(mod_id) or []:
        deps.append({
            'modId': d.get('modId') or '',
            'type': d.get('type') or 'required',
            'versionRange': d.get('versionRange') or '',
        })
    minecraft = next((d['versionRange'] for d in deps if d['modId'] == 'minecraft'), '')
    return {
        'id': mod_id,
        'name': m.get('displayName') or mod_id,
        'version': m.get('version') or '',
        'modloader': loader,
        'description': m.get('description') or '',
        'environment': '',
        'minecraft': minecraft,
        'dependencies': deps,
    }


def read_mod_version_manifest(file_entries):
    """Build the Mods manifest for a mod-category version.

    file_entries: list of (filename, path). Accepts standalone .jar files and
    .zip archives whose root holds .jar members (subfolders are ignored).
    Returns {mods: [...], no_mods: bool}.
    """
    mods = []
    for filename, path in file_entries:
        lower = filename.lower()
        if lower.endswith('.jar'):
            meta = read_mod_manifest(path)
            if meta:
                if not meta.get('minecraft'):
                    meta['minecraft'] = _mc_from_filename(filename)
                meta['file_name'] = filename
                meta['member'] = ''
                mods.append(meta)
        elif lower.endswith('.zip'):
            zf = _open_zip(path)
            if zf is None:
                continue
            try:
                root_jars = sorted(n for n in zf.namelist() if n.lower().endswith('.jar') and '/' not in n)
                for member in root_jars:
                    meta = read_mod_manifest(zf.read(member))
                    if meta:
                        if not meta.get('minecraft'):
                            meta['minecraft'] = _mc_from_filename(member)
                        meta['file_name'] = member
                        meta['member'] = member
                        mods.append(meta)
            finally:
                zf.close()
    return {'mods': mods, 'no_mods': len(mods) == 0}


def _in_plugins_dir(name):
    """True when a member lives inside a plugins/ folder (any nesting)."""
    parts = name.split('/')
    return 'plugins' in parts[:-1]


def read_plugins_manifest(path):
    """Scan the archive's plugins/ folder for Blockbench plugins.

    A ``.js`` file under a ``plugins/`` directory that registers a Blockbench
    plugin (contains ``Plugin.register(`` or ``BBPlugin.register(``) is listed.
    ``plugins/autoload.txt`` holds one plugin name per line; matching plugins
    are marked ``autoload`` so the viewer can hotload them when a model opens.
    Returns None when the pack has no plugins.
    """
    zf = _open_zip(path)
    if zf is None:
        return None
    try:
        names = set(zf.namelist())
        autoload = set()
        for n in names:
            if _in_plugins_dir(n) and n.endswith('autoload.txt'):
                try:
                    raw = zf.read(n).decode('utf-8', 'replace')
                except KeyError:
                    continue
                for line in raw.splitlines():
                    line = line.strip()
                    if line and not line.startswith('#'):
                        autoload.add(line)

        plugins = []
        for n in sorted(names):
            if not _in_plugins_dir(n) or not n.endswith('.js'):
                continue
            try:
                content = zf.read(n)
            except KeyError:
                continue
            if len(content) > 8 * 1024 * 1024:
                continue
            text = content.decode('utf-8', 'replace')
            if 'Plugin.register(' not in text and 'BBPlugin.register(' not in text:
                continue
            base = n.rsplit('/', 1)[-1]
            stem = base[:-3] if base.lower().endswith('.js') else base
            plugins.append({
                'name': base,
                'member': n,
                'size': len(content),
                'autoload': base in autoload or stem in autoload,
            })

        if not plugins:
            return None
        return {
            'plugins': plugins,
            'has_plugins': True,
            'autoload_count': sum(1 for p in plugins if p['autoload']),
        }
    finally:
        zf.close()


def _decode(data):
    """Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
    return json.loads(data.decode('utf-8-sig', 'replace'))


def _open_zip(path):
    """Return a ZipFile for the path (None when it's not a zip)."""
    try:
        return zipfile.ZipFile(path)
    except (zipfile.BadZipFile, OSError):
        return None


DEFAULT_PACK_BLOCK = {
    'pack_format': 64,
    'supported_formats': [64, 81],
    'min_format': 64,
    'max_format': 81,
}


def inject_animationframework(path, fields, create_pack_block=None):
    """Rewrite the zip at ``path`` in place so pack.mcmeta carries the
    animationframework block (id/name/author/version/description).

    When the archive already has pack.mcmeta, only the animationframework key
    is added/updated and every other field is preserved. When it has none, a
    whole pack.mcmeta is created (pack block from ``create_pack_block``, which
    defaults to the current NoN pack format). Returns True on success.
    """
    tmp = f'{path}.nfnorm'
    try:
        with zipfile.ZipFile(path, 'r') as zin:
            names = set(zin.namelist())
            if 'pack.mcmeta' in names:
                try:
                    mcmeta = _decode(zin.read('pack.mcmeta'))
                except (KeyError, json.JSONDecodeError):
                    mcmeta = {}
                if not isinstance(mcmeta.get('pack'), dict):
                    mcmeta['pack'] = dict(create_pack_block or DEFAULT_PACK_BLOCK)
            else:
                block = dict(create_pack_block or DEFAULT_PACK_BLOCK)
                mcmeta = {'pack': block}

            mcmeta['animationframework'] = fields

            with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout:
                for item in zin.infolist():
                    if item.filename == 'pack.mcmeta':
                        continue
                    zout.writestr(item, zin.read(item.filename))
                zout.writestr('pack.mcmeta', json.dumps(mcmeta, indent=2))
        os.replace(tmp, path)
        return True
    except (OSError, zipfile.BadZipFile):
        try:
            if os.path.exists(tmp):
                os.remove(tmp)
        except OSError:
            pass
        return False


def read_pack_mcmeta(path):
    """Extract the NoN-relevant data from a datapack's pack.mcmeta.

    Returns (pack_format, description) or (None, '').
    """
    zf = _open_zip(path)
    if zf is None:
        return None, ''
    try:
        try:
            data = _decode(zf.read('pack.mcmeta'))
        except (KeyError, json.JSONDecodeError):
            return None, ''
        pack = data.get('pack') or {}
        try:
            pack_format = int(pack.get('pack_format'))
        except (TypeError, ValueError):
            pack_format = None
        description = pack.get('description', '') or ''
        if isinstance(description, (dict, list)):
            description = json.dumps(description)
        return pack_format, str(description)
    finally:
        zf.close()


def _friendly_entity(entity):
    """minecraft:zombie -> Zombie; needsofnature:horse_liquid_collector -> Horse Liquid Collector."""
    name = entity.rsplit(':', 1)[-1]
    return name.replace('_', ' ').title()


def _gender(actor_tags):
    for tag in actor_tags or []:
        if str(tag).startswith('gender.'):
            return str(tag).split('.', 1)[1]
    return None


def _summarize_animdef(filename, data):
    stem = filename.rsplit('/', 1)[-1]
    if stem.endswith('.json'):
        stem = stem[:-5]
    display = data.get('display_name') or stem.replace('_', ' ').title()

    participants = len(data.get('actors') or [])
    actors = []
    has_entity = False
    entity_names = []
    for actor in data.get('actors') or []:
        entity_types = actor.get('entity_types') or []
        entity = 'player'
        if entity_types:
            non_players = [e for e in entity_types if e != 'minecraft:player']
            if non_players:
                entity = _friendly_entity(non_players[0])
                has_entity = True
                entity_names.append(entity)
        injector = actor.get('injector')
        inj = None
        inj_name = None
        if injector is True:
            inj = 'injector'
            inj_name = 'injector'
        elif isinstance(injector, str) and injector:
            inj = injector
            inj_name = INJECTOR_NAMES.get(injector, injector)
        actors.append({
            'label': actor.get('label') or '',
            'entity': entity,
            'gender': _gender(actor.get('actor_tags')),
            'activity': actor.get('activity') or '',
            'injector': inj,
            'injector_name': inj_name,
            'receiver': bool(actor.get('receiver')),
        })

    content_tags = [t for t in data.get('content_tags') or []]
    animation_tags = data.get('animation_tags') or []
    problem_tags = sorted({
        t for t in list(content_tags) + list(animation_tags)
        if str(t).lower() in PROBLEM_TAGS
    })

    if participants == 1:
        type_ = 'Solo'
    elif has_entity:
        type_ = 'Entity x Player'
    elif participants == 2:
        type_ = 'Pair'
    elif participants == 3:
        type_ = 'Threesome'
    else:
        type_ = f'Group of {participants}'

    stages = []
    for stage in data.get('stages') or []:
        stages.append({
            'stage': stage.get('stage'),
            'loop': bool(stage.get('loop')),
            'cycle_seconds': stage.get('cycle_seconds'),
            'speed': stage.get('speed'),
            'climax': bool(stage.get('non_peak') or stage.get('manual_peak')),
            'use_stage': stage.get('use_stage'),
            'joinable': bool(stage.get('allow_join', True)),
            'escapable': bool(stage.get('escapable', True)),
        })

    return {
        'name': display,
        'content_tags': content_tags,
        'animation_tags': list(animation_tags),
        'type': type_,
        'entity_names': entity_names,
        'participants': participants,
        'actors': actors,
        'stages': stages,
        'weight': data.get('weight'),
        'block_requirements': bool(data.get('block_requirements')),
        'water': bool(data.get('water')),
        'problem_tags': problem_tags,
    }


def read_pack_meta(path):
    """Read the animationframework block from pack.mcmeta regardless of whether
    the pack has animdefs (models/textures packs). None when absent."""
    zf = _open_zip(path)
    if zf is None:
        return None
    try:
        try:
            mcmeta = _decode(zf.read('pack.mcmeta'))
        except (KeyError, json.JSONDecodeError):
            return None
        af = mcmeta.get('animationframework')
        if not af:
            return None
        authors = []
        if af.get('author'):
            authors = [a.strip() for a in str(af['author']).split(',') if a.strip()]
        return {
            'animation_id': af.get('id') or None,
            'name': str(af.get('name') or ''),
            'version': str(af.get('version') or ''),
            'authors': authors,
            'description': str(af.get('description') or ''),
        }
    finally:
        zf.close()


def _resource_to_member(resource):
    """needsofnature:textures/entity/zombie/zombie.png → assets/needsofnature/..."""
    if ':' in resource:
        ns, _, path = resource.partition(':')
        return f'assets/{ns}/{path}'
    if resource.startswith('assets/'):
        return resource
    return f'assets/{resource}'


def read_models_manifest(path, category='non_pack'):
    """Build the Models/Textures manifest.

    For the explicit ``model`` category this scans the archive for any
    Blockbench-compatible model file (``MODEL_FILE_EXTENSIONS``) anywhere in
    the zip. For other categories it keeps the NoN GeckoLib geo-model
    detection (``geckolib/models/*.geo.json``). Returns None when no models
    are found. Each model records its filename, zip member, format, bone/cube
    counts, and the afw_bone_textures map (bone → texture zip member).
    """
    zf = _open_zip(path)
    if zf is None:
        return None
    try:
        names = set(zf.namelist())
        if category == 'model':
            model_names = sorted(
                n for n in names
                if not n.endswith('/') and n.lower().endswith(MODEL_FILE_EXTENSIONS)
            )
        else:
            model_names = sorted(
                n for n in names if '/geckolib/models/' in n and n.endswith('.geo.json')
            )
        if not model_names:
            return None

        models = []
        for name in model_names:
            ext = _model_ext(name)
            data = None
            try:
                data = _decode(zf.read(name))
            except (KeyError, json.JSONDecodeError, UnicodeDecodeError):
                data = None

            bone_textures = {}
            bone_count = 0
            cube_count = 0
            identifier = ''
            if data and isinstance(data, dict):
                for bone, tex in (data.get('afw_bone_textures') or {}).items():
                    member = _resource_to_member(str(tex))
                    if member in names:
                        bone_textures[str(bone)] = member
                if ext == '.geo.json':
                    for geom in data.get('minecraft:geometry') or []:
                        identifier = identifier or (geom.get('description') or {}).get('identifier', '')
                        for bone in geom.get('bones') or []:
                            bone_count += 1
                            cube_count += len(bone.get('cubes') or [])
                elif ext == '.jem':
                    bone_count = len(data.get('models') or [])

            entity = _entity_name(name)
            default_texture = ''
            if not bone_textures and entity:
                for n in names:
                    if n.endswith(f'/textures/entity/{entity}/{entity}.png') or n.endswith(f'/textures/entity/{entity}.png'):
                        default_texture = n
                        break
            models.append({
                'name': name.rsplit('/', 1)[-1],
                'member': name,
                'ext': ext,
                'mode': 'pack',
                'identifier': identifier,
                'entity': entity,
                'default_texture': default_texture,
                'bones': bone_count,
                'cubes': cube_count,
                'has_embedded_texture': ext in ('.bbmodel', '.gltf', '.glb'),
                'bone_textures': bone_textures,
                'textures': sorted(set(bone_textures.values())),
            })

        textures = sorted(n for n in names if n.startswith('assets/') and n.endswith('.png'))
        return {'models': models, 'textures': textures}
    finally:
        zf.close()


def _model_ext(name):
    """The matching MODEL_FILE_EXTENSIONS suffix for a member, or ''."""
    lower = name.lower()
    for ext in MODEL_FILE_EXTENSIONS:
        if lower.endswith(ext):
            return ext
    return ''


def _entity_name(name):
    """Derive the entity name from a model filename (strip ext + gender suffix)."""
    base = name.rsplit('/', 1)[-1]
    for ext in MODEL_FILE_EXTENSIONS:
        if base.lower().endswith(ext):
            base = base[: -len(ext)]
            break
    for suffix in ('.mf', '.fm', '.m', '.f', '.g'):
        if base.endswith(suffix):
            base = base[:-len(suffix)]
            break
    return base


def _item_name_from_member(member):
    parts = member.split('/')
    try:
        idx = parts.index('items')
        ns = parts[idx - 1]
        name = parts[idx + 1]
        if name.endswith('.json'):
            name = name[:-5]
        return f'{ns}:{name}'
    except (ValueError, IndexError):
        return member


def _ingredient_names(recipe):
    if isinstance(recipe.get('key'), dict):
        raw = list(recipe['key'].values())
    elif 'ingredients' in recipe:
        raw = recipe['ingredients']
    else:
        raw = []
    names = set()
    for item in raw:
        if isinstance(item, dict):
            item = item.get('id')
        if item:
            names.add(str(item))
    return sorted(names)


def _result_name(recipe):
    res = recipe.get('result')
    if isinstance(res, dict):
        rid = res.get('id') or res.get('item') or ''
        count = res.get('count')
        if count and int(count) != 1:
            return f'{rid} x{count}'
        return str(rid)
    return str(res) if res else ''


def read_logical_manifest(path):
    """Build the Logical manifest outlining what a datapack adds (items,
    recipes, custom crafting, advancements, functions, NoN extras)."""
    zf = _open_zip(path)
    if zf is None:
        return None
    try:
        names = set(zf.namelist())

        def _dir_members(subdir, suffix):
            """data/<ns>/<subdir>/<file> or assets/<ns>/<subdir>/<file> (4 parts)."""
            out = []
            for n in names:
                parts = n.split('/')
                if len(parts) == 4 and parts[2] == subdir and n.endswith(suffix):
                    out.append(n)
            return sorted(out)

        items = _dir_members('items', '.json')
        recipes = _dir_members('recipe', '.json')
        functions = sorted(
            n for n in names
            if n.startswith('data/') and '/function/' in n and n.endswith('.mcfunction')
        )
        liquid_gains = [n for n in names if '/non_liquid_gains/' in n]
        entity_profiles = [n for n in names if '/non_entity_profiles/' in n]
        trinkets = [n for n in names if '/trinkets/' in n]

        if not any([items, recipes, functions, liquid_gains, entity_profiles, trinkets]):
            return None

        advancement_members = sorted(
            n for n in names
            if n.startswith('data/') and '/advancement/' in n and n.endswith('.json')
        )
        custom_recipes = [n for n in advancement_members if '/advancement/recipe/' in n]
        advancements_plain = [n for n in advancement_members if '/advancement/recipe/' not in n]

        item_list = []
        for member in items:
            name = _item_name_from_member(member)
            ns, _, base = name.partition(':')
            tex_member = f'assets/{ns}/textures/item/{base}.png'
            item_list.append({
                'name': name,
                'texture': tex_member if tex_member in names else None,
            })

        recipe_list = []
        for member in recipes:
            base = member.rsplit('/', 1)[-1]
            disabled = '.disabled' in base
            clean = base.replace('.json', '').replace('.disabled', '')
            try:
                data = _decode(zf.read(member))
            except (KeyError, json.JSONDecodeError):
                data = {}
            recipe_list.append({
                'name': clean,
                'type': (data.get('type') or '').rsplit(':', 1)[-1],
                'ingredients': _ingredient_names(data),
                'result': _result_name(data),
                'disabled': disabled,
            })

        custom_recipes_out = []
        advancements_out = []
        for member in advancement_members:
            try:
                data = _decode(zf.read(member))
            except (KeyError, json.JSONDecodeError):
                continue
            reward = (data.get('rewards') or {}).get('function')
            name = member.rsplit('/', 1)[-1][:-5]
            if member in custom_recipes:
                custom_recipes_out.append({'name': name, 'reward': reward})
            else:
                advancements_out.append({'name': name, 'reward': reward})

        function_list = sorted(n for n in functions if '/recipe/' not in n)

        return {
            'items': item_list,
            'recipes': recipe_list,
            'custom_recipes': custom_recipes_out,
            'advancements': advancements_out,
            'functions': function_list,
            'custom_functions': len(functions) - len(function_list),
            'liquid_gains': liquid_gains,
            'entity_profiles': entity_profiles,
            'trinkets': trinkets,
        }
    finally:
        zf.close()


def read_animation_manifest(path):
    """Build the animation manifest for a NoN animation pack.

    Detected by the presence of data/*/afw_animdefs/*.json. Returns None when
    the archive isn't an animation pack. Reads the animationframework block
    (id/name/version/authors/description) plus a summary of every animdef.
    """
    zf = _open_zip(path)
    if zf is None:
        return None
    try:
        names = set(zf.namelist())
        animdef_names = sorted(
            n for n in names if '/afw_animdefs/' in n and n.endswith('.json')
        )
        if not animdef_names:
            return None

        animation_id = None
        pack_name = ''
        pack_version = ''
        description = ''
        authors = []
        try:
            mcmeta = _decode(zf.read('pack.mcmeta'))
            af = mcmeta.get('animationframework') or {}
            animation_id = af.get('id') or None
            pack_name = str(af.get('name') or '')
            pack_version = str(af.get('version') or '')
            description = str(af.get('description') or '')
            author = af.get('author')
            if author:
                authors = [a.strip() for a in str(author).split(',') if a.strip()]
        except (KeyError, json.JSONDecodeError):
            pass

        animations = []
        distinct_content_tags = set()
        for name in animdef_names:
            try:
                data = _decode(zf.read(name))
            except (KeyError, json.JSONDecodeError):
                continue
            summary = _summarize_animdef(name, data)
            distinct_content_tags.update(summary['content_tags'])
            animations.append(summary)

        return {
            'animation_id': animation_id,
            'name': pack_name,
            'version': pack_version,
            'authors': authors,
            'description': description,
            'animations': animations,
            'content_tags': sorted(distinct_content_tags),
        }
    finally:
        zf.close()


def _normalize_modloader(loader_id):
    """'fabricloader-0.16.9' → 'fabric', 'neoforge-21.1.66' → 'neoforge'."""
    low = str(loader_id or '').lower().strip()
    if not low:
        return ''
    for prefix, name in (
        ('fabricloader', 'fabric'),
        ('neoforge', 'neoforge'),
        ('quilt', 'quilt'),
        ('liteloader', 'liteloader'),
        ('forge', 'forge'),
        ('fml', 'forge'),
        ('rift', 'rift'),
        ('vanilla', 'vanilla'),
    ):
        if low.startswith(prefix):
            return name
    return low.split('-')[0]


def _mod_env(env):
    """Normalize an mrpack env object ({client, server}) to a label."""
    client = str((env or {}).get('client') or '')
    server = str((env or {}).get('server') or '')
    if 'optional' in (client, server):
        return 'optional'
    if client == 'required' and server == 'unsupported':
        return 'client'
    if server == 'required' and client == 'unsupported':
        return 'server'
    return ''


def _modpack_totals(files):
    total = len(files)
    if any('required' in f for f in files):
        required = sum(1 for f in files if f.get('required'))
        optional = total - required
    elif any('env' in f for f in files):
        optional = sum(1 for f in files if f.get('env') == 'optional')
        required = total - optional
    else:
        required = optional = None
    return {
        'total': total,
        'required': required,
        'optional': optional,
        'client': sum(1 for f in files if f.get('env') == 'client'),
        'server': sum(1 for f in files if f.get('env') == 'server'),
    }


def _loader_from_filename(filename):
    """Best-effort loader from a mod filename ('x_FABRIC_1.0.jar' → 'fabric')."""
    low = str(filename).lower()
    for token in ('fabricloader', 'fabric', 'neoforge', 'forge', 'quilt', 'rift', 'liteloader'):
        if token in low:
            return _normalize_modloader(token)
    return ''


def _modpack_loaders(zf, members, limit=12):
    """Unique modloaders inferred for a modpack.

    Folder/instance archives embed their jars, so we parse the first few for
    their metadata. Modrinth .mrpack files only carry config overrides, so we
    fall back to the loader encoded in the mod filenames. A sample is enough
    because a modpack almost always targets a single loader.
    """
    loaders = []
    for member in members[:limit]:
        loader = ''
        if str(member).lower().endswith('.jar'):
            try:
                meta = read_mod_manifest(zf.read(member))
            except Exception:
                meta = None
            loader = (meta or {}).get('modloader') or ''
        if not loader:
            loader = _loader_from_filename(member)
        if loader and loader not in loaders:
            loaders.append(loader)
    return loaders


def parse_mods_manifest(path, filename=''):
    """Inspect a modpack archive and build a manifest for the Mods tab.

    - Modrinth .mrpack: modrinth.index.json → per-file paths + env
    - Curseforge modpack: manifest.json → MC version, loaders, file list
    - Full instance / folder: mods under mods/, minecraft/mods/, .minecraft/mods/
    Returns a dict: {source, minecraft, modloaders, files, totals}.
    """
    zf = _open_zip(path)
    if zf is None:
        return _modpack_result('unknown', '', [], [])

    try:
        names = set(zf.namelist())

        if filename.lower().endswith('.mrpack') or 'modrinth.index.json' in names:
            try:
                index = json.loads(zf.read('modrinth.index.json').decode('utf-8', 'replace'))
            except (KeyError, json.JSONDecodeError):
                index = {}
            files = []
            for entry in index.get('files') or []:
                path_name = str(entry.get('path') or '').replace('\\', '/')
                if not path_name:
                    continue
                files.append({
                    'name': path_name.rsplit('/', 1)[-1],
                    'file_name': path_name,
                    'env': _mod_env(entry.get('env')),
                })
            modloaders = _modpack_loaders(zf, [f['file_name'] for f in files])
            return _modpack_result('modrinth', '', modloaders, files)

        if 'manifest.json' in names:
            try:
                manifest = json.loads(zf.read('manifest.json').decode('utf-8', 'replace'))
            except (KeyError, json.JSONDecodeError):
                manifest = {}
            mc = manifest.get('minecraft') or {}
            minecraft = str(mc.get('version') or '')
            modloaders = []
            for loader in mc.get('modLoaders') or []:
                norm = _normalize_modloader(loader.get('id'))
                if norm and norm not in modloaders:
                    modloaders.append(norm)
            files = []
            for entry in manifest.get('files') or []:
                file_name = str(entry.get('fileName') or '')
                slug = str(entry.get('projectSlug') or '')
                project_id = entry.get('projectID')
                pid = str(project_id) if project_id else ''
                name = slug or file_name or (f'Mod #{pid}' if pid else 'unknown')
                files.append({
                    'name': name,
                    'file_name': file_name,
                    'required': bool(entry.get('required', True)),
                    'slug': slug,
                    'project_id': pid,
                    'url': f'https://www.curseforge.com/projects/{pid}' if pid else '',
                })
            if files or minecraft or modloaders:
                return _modpack_result('curseforge', minecraft, modloaders, files)

        # Full instance / folder: mod jars under a mods/ dir at any nesting
        # (minecraft/mods, .minecraft/mods, <instance>/minecraft/mods, mods).
        mods = []
        seen = set()
        is_instance = False
        for name in sorted(names):
            if name.endswith('/') or not name.lower().endswith('.jar'):
                continue
            parts = name.split('/')
            if 'mods' not in parts or parts.index('mods') >= len(parts) - 1:
                continue
            # Full instance = .minecraft/mods or a nested <instance>/minecraft/mods;
            # a root-level minecraft/mods folder is the classic folder layout.
            if '.minecraft' in parts or (('minecraft' in parts) and parts.index('minecraft') > 0):
                is_instance = True
            fname = parts[-1]
            if fname and fname not in seen:
                seen.add(fname)
                mods.append({'name': fname, 'file_name': name})
        if mods:
            modloaders = _modpack_loaders(zf, [m['file_name'] for m in mods])
            return _modpack_result('instance' if is_instance else 'folder', '', modloaders, mods)

        return _modpack_result('unknown', '', [], [])
    finally:
        zf.close()


def _modpack_result(source, minecraft, modloaders, files):
    return {
        'source': source,
        'minecraft': minecraft,
        'modloaders': modloaders,
        'files': files,
        'totals': _modpack_totals(files),
    }
