"""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 json
import os
import zipfile

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


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):
    """Build the Models/Textures manifest from GeckoLib geo models.

    Returns None when the pack has no geo models. Each model records its geo
    filename, identifier, bone/cube counts, and the afw_bone_textures map
    (bone → texture zip member) so the viewer can texture it."""
    zf = _open_zip(path)
    if zf is None:
        return None
    try:
        names = set(zf.namelist())
        geo_names = sorted(
            n for n in names if '/geckolib/models/' in n and n.endswith('.geo.json')
        )
        if not geo_names:
            return None

        models = []
        for name in geo_names:
            try:
                data = _decode(zf.read(name))
            except (KeyError, json.JSONDecodeError):
                continue
            bone_textures = {}
            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
            bone_count = 0
            cube_count = 0
            identifier = ''
            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 [])

            entity = name.rsplit('/', 1)[-1]
            if entity.endswith('.geo.json'):
                entity = entity[:-len('.geo.json')]
            for suffix in ('.mf', '.fm', '.m', '.f', '.g'):
                if entity.endswith(suffix):
                    entity = entity[:-len(suffix)]
                    break

            # A pack-provided full skin is the model's default texture when the
            # model has no per-bone textures of its own.
            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,
                'identifier': identifier,
                'entity': entity,
                'default_texture': default_texture,
                'bones': bone_count,
                'cubes': cube_count,
                '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 _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 parse_mods_manifest(path, filename=''):
    """Inspect a modpack archive and build a manifest for the Mods tab.

    - Modrinth .mrpack: modrinth.index.json → dependencies
    - Curseforge modpack: manifest.json → files array
    - Classic folder layout: minecraft/mods/*.jar
    Returns a dict: {source, files: [...]}.
    """
    zf = _open_zip(path)
    if zf is None:
        return {'source': 'unknown', 'files': []}

    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 = {}
            deps = []
            for dep in index.get('dependencies') or []:
                deps.append({
                    'name': dep.get('file_name') or dep.get('project_id', 'unknown'),
                    'version_id': dep.get('version_id'),
                    'type': dep.get('dependency_type', ''),
                })
            return {'source': 'modrinth', 'files': deps}

        if 'manifest.json' in names:
            try:
                manifest = json.loads(zf.read('manifest.json').decode('utf-8', 'replace'))
            except (KeyError, json.JSONDecodeError):
                manifest = {}
            files = []
            for entry in manifest.get('files') or []:
                files.append({
                    'name': entry.get('fileName', entry.get('projectID', 'unknown')),
                    'project_id': entry.get('projectID'),
                    'file_id': entry.get('fileID'),
                })
            if files:
                return {'source': 'curseforge', 'files': files}

        mods = []
        prefix = 'minecraft/mods/'
        for name in sorted(names):
            if name.startswith(prefix) and not name.endswith('/'):
                mods.append({'name': name[len(prefix):]})
        if mods:
            return {'source': 'folder', 'files': mods}

        return {'source': 'unknown', 'files': []}
    finally:
        zf.close()
