# ============================================================
# mn_style_helper.py  --  per-chain Molecular Nodes styling
# ============================================================
# Loads ONLY the styling functions into the Blender Python Console
# (no scene rebuild). In the Console, run once:
#
#   exec(open(r"/FULL/PATH/TO/mn_style_helper.py").read())
#
# ...then call it after you've imported the molecule via Molecular Nodes:
#
#   style_by_chain(bpy.data.objects["wheat_pmhc"],
#                  {"Style Cartoon":        ["A", "B"],
#                   "Style Ball and Stick": ["P"]})   # "Style Spheres" = spacefill
#
# Confirm the peptide's chain letter in the MN spreadsheet first if unsure.
#
# CHANGE LOG
#   - Fixed node-instance group accessor: a node instance references its
#     group via `.node_tree`, NOT `.node_group` (the latter is correct only
#     on the modifier). Earlier versions raised AttributeError and never
#     actually applied styling.
#   - Chain selection no longer requires MN's "Select Chain" node group to
#     be added by hand. It is built from stock Geometry Nodes (Named
#     Attribute `chain_id` + integer Compare + Boolean OR). MN stores
#     chain_id as an INT attribute, sorted alphabetically (A->0, B->1, ...);
#     the letter->int map is validated against the atom mesh. You may also
#     pass integer chain indices directly instead of letters.
#   - Missing Style node groups (e.g. "Style Ball and Stick", absent until
#     used) are now auto-appended from MN's bundled asset .blend files, so
#     you no longer need Shift+A -> Molecular Nodes -> Style by hand.
# ============================================================
import bpy

def _find_node_group(name):
    """Return an MN node group by tolerant name match, or None.
    Matches 'Style Ball and Stick' against 'Style Ball and Stick',
    'MN_style_ball_and_stick', etc."""
    def norm(s):
        return "".join(c for c in s.lower() if c.isalnum())
    target = norm(name)
    # exact-ish first
    for ng in bpy.data.node_groups:
        if norm(ng.name) == target:
            return ng
    # then containment (handles 'MN_style_' prefixes / suffixes)
    for ng in bpy.data.node_groups:
        if target in norm(ng.name) or norm(ng.name).endswith(target):
            return ng
    # not present in this file -> try appending it from MN's asset blends
    return _append_from_mn_assets(name)


def _append_from_mn_assets(name):
    """Append a style/selection node group from Molecular Nodes' bundled
    asset .blend files when it is not already in the current file.

    MN only loads the style node groups you actually use on import, so
    e.g. 'Style Ball and Stick' is absent until you add it. This locates
    the MN addon directory, scans its .blend assets, and appends the first
    node group whose name matches `name` (tolerant match). Blender pulls in
    any nested node groups the style depends on automatically. Returns the
    appended node group, or None if it could not be found."""
    import addon_utils, os, glob, sys

    def norm(s):
        return "".join(c for c in s.lower() if c.isalnum())
    target = norm(name)

    # 1. collect candidate MN addon directories
    mn_dirs = []
    for m in addon_utils.modules():
        if "molecul" in m.__name__.lower() and getattr(m, "__file__", None):
            d = os.path.dirname(m.__file__)
            if d not in mn_dirs:
                mn_dirs.append(d)
    for k, mod in list(sys.modules.items()):
        if "molecularnodes" in k.lower() and getattr(mod, "__file__", None):
            d = os.path.dirname(mod.__file__)
            if os.path.isdir(d) and d not in mn_dirs:
                mn_dirs.append(d)

    # 2. glob every bundled .blend
    blends = []
    for d in mn_dirs:
        blends += glob.glob(os.path.join(d, "**", "*.blend"), recursive=True)
    seen = set()
    blends = [b for b in blends if not (b in seen or seen.add(b))]
    if not blends:
        print(f"[style_by_chain] Could not locate MN asset .blend files "
              f"under {mn_dirs} to append '{name}'.")
        return None

    # 3. append the first matching node group
    for bpath in blends:
        try:
            match = None
            with bpy.data.libraries.load(bpath, link=False) as (src, dst):
                for ng_name in src.node_groups:
                    if norm(ng_name) == target:
                        match = ng_name
                        break
                if match is None:
                    for ng_name in src.node_groups:
                        if target and target in norm(ng_name):
                            match = ng_name
                            break
                if match is not None:
                    dst.node_groups = [match]
            if match is not None:
                for ng in bpy.data.node_groups:
                    if norm(ng.name) == norm(match):
                        print(f"[style_by_chain] Appended '{ng.name}' from "
                              f"{os.path.basename(bpath)}.")
                        return ng
        except Exception as e:
            print(f"[style_by_chain] (skip {os.path.basename(bpath)}: {e})")
            continue

    print(f"[style_by_chain] '{name}' not found in any MN asset blend "
          f"({len(blends)} scanned).")
    return None

def _chain_id_map(mol_obj):
    """Return {chain_letter: int_value} for the MN chain_id attribute.

    MN assigns chain_id ints by sorting the unique chain letters
    alphabetically (A->0, B->1, ...). We just need the SET of letters, which
    MN stashes as a custom property on the object. We try the known property
    names across MN versions; if none is found we return {} and the caller
    falls back to an alphabetical-ordinal guess."""
    candidates = ("chain_ids", "chain_id_unique", "chain_id", "entity_names")
    letters = None
    # object-level custom properties
    for key in candidates:
        try:
            val = mol_obj[key]
        except (KeyError, TypeError):
            val = None
        if val is None:
            continue
        try:
            seq = list(val)
        except TypeError:
            continue
        if seq and all(isinstance(x, str) for x in seq):
            letters = seq
            break
    # newer MN exposes a .mn property group
    if letters is None:
        mn = getattr(mol_obj, "mn", None)
        for key in candidates:
            v = getattr(mn, key, None) if mn is not None else None
            if v:
                try:
                    seq = list(v)
                    if seq and all(isinstance(x, str) for x in seq):
                        letters = seq
                        break
                except TypeError:
                    pass
    if not letters:
        return {}
    uniq = sorted(set(letters))
    return {c.upper(): i for i, c in enumerate(uniq)}


def style_by_chain(mol_obj, chain_styles):
    """Wire a per-chain multi-style Molecular Nodes graph.

    mol_obj      : the object MN created on import.
    chain_styles : {style_node_group_name: [chain_id, ...], ...}
                   chain ids are letters ("A","B","P") OR integer indices.
                   e.g. {"Style Cartoon": ["A","B"],
                         "Style Ball and Stick": ["P"]}
    Returns the modified GeometryNodes node tree.
    Selection is built from stock Geometry Nodes (Named Attribute chain_id
    + Compare + Boolean OR), so MN's Select Chain node is NOT required.
    Raises RuntimeError only if a requested Style node group is missing."""
    # 1. locate the GeometryNodes modifier + its node tree
    mod = next((m for m in mol_obj.modifiers if m.type == 'NODES'), None)
    if mod is None or mod.node_group is None:
        raise RuntimeError(
            f"'{mol_obj.name}' has no Geometry Nodes modifier -- is this "
            "really the object Molecular Nodes created on import?")
    tree = mod.node_group
    nodes, links = tree.nodes, tree.links

    def geo_out(node):
        return next((s for s in node.outputs if s.type == 'GEOMETRY'), None)
    def geo_in(node):
        return next((s for s in node.inputs if s.type == 'GEOMETRY'), None)

    grp_in  = next(n for n in nodes if n.type == 'GROUP_INPUT')
    grp_out = next(n for n in nodes if n.type == 'GROUP_OUTPUT')

    # 2. find the current Style node and the socket feeding its geometry
    #    input -- that socket carries the colored atom geometry we want to
    #    branch from (so per-chain colors are preserved).
    existing_style = next(
        (n for n in nodes
         if n.type == 'GROUP' and getattr(n, "node_tree", None)
         and n.node_tree.name.lower().startswith((".mn_style", "mn_style",
                                                   "style"))),
        None)
    if existing_style is not None and geo_in(existing_style) and geo_in(existing_style).links:
        source_socket = geo_in(existing_style).links[0].from_socket
    else:
        # fall back to whatever currently feeds Group Output, else raw atoms
        go_in = geo_in(grp_out)
        source_socket = go_in.links[0].from_socket if (go_in and go_in.links) else geo_out(grp_in)

    # 3. build the chain letter -> integer map by reading the chain_id
    #    attribute off the atom mesh. MN stores chain_id as an INT attribute
    #    whose values are the alphabetically-sorted chain letters mapped to
    #    0,1,2,... We recover that mapping from the source PDB chain order so
    #    the letters you pass ("A","B","P") resolve to the right integers.
    chain_map = _chain_id_map(mol_obj)   # {"A":0,"B":1,"P":2} or {} if unknown

    def resolve_ids(chain_ids):
        """Turn ['A','B'] (or [0,1]) into the integer chain_id values."""
        out = []
        for c in chain_ids:
            if isinstance(c, int):
                out.append(c)
            elif chain_map and c.upper() in chain_map:
                out.append(chain_map[c.upper()])
            else:
                # last resort: alphabetical ordinal (A=0,B=1,...) if it is a
                # single A-Z letter; otherwise skip with a warning
                cu = c.upper()
                if len(cu) == 1 and "A" <= cu <= "Z":
                    out.append(ord(cu) - ord("A"))
                    print(f"[style_by_chain] NOTE: chain '{c}' not found in "
                          f"attribute map {chain_map}; assuming ordinal "
                          f"{ord(cu) - ord('A')}. Verify in the viewport.")
                else:
                    print(f"[style_by_chain] WARNING: cannot resolve chain "
                          f"'{c}' to an integer; skipping.")
        return out

    # 4. remove the old single-style node so we don't double-draw chains
    if existing_style is not None:
        nodes.remove(existing_style)

    # 5. one Join Geometry to merge all branches
    join = nodes.new("GeometryNodeJoinGeometry")
    join.location = (900, 0)
    links.new(join.outputs["Geometry"], geo_in(grp_out))

    def make_chain_selection(int_ids, y):
        """Stock-node boolean selection: OR of (chain_id == k) for each k.
        Returns the output socket carrying the boolean selection."""
        named = nodes.new("GeometryNodeInputNamedAttribute")
        named.data_type = 'INT'
        named.inputs["Name"].default_value = "chain_id"
        named.location = (-200, y)
        attr_out = named.outputs["Attribute"]

        prev_bool = None
        for j, k in enumerate(int_ids):
            cmp = nodes.new("FunctionNodeCompare")
            cmp.data_type = 'INT'
            cmp.operation = 'EQUAL'
            cmp.location = (20, y - j * 60)
            links.new(attr_out, cmp.inputs["A"])
            # the integer B socket (name differs by type; grab by position)
            b_sock = next(s for s in cmp.inputs
                          if s.enabled and s.type == 'INT' and s != cmp.inputs.get("A"))
            b_sock.default_value = k
            this_bool = cmp.outputs["Result"]
            if prev_bool is None:
                prev_bool = this_bool
            else:
                orn = nodes.new("FunctionNodeBooleanMath")
                orn.operation = 'OR'
                orn.location = (220, y - j * 60)
                links.new(prev_bool, orn.inputs[0])
                links.new(this_bool, orn.inputs[1])
                prev_bool = orn.outputs["Boolean"]
        return prev_bool

    # 6. build one branch per style
    y = 300
    for style_name, chain_ids in chain_styles.items():
        style_ng = _find_node_group(style_name)
        if style_ng is None:
            nodes.remove(join)
            raise RuntimeError(
                f"Style node group '{style_name}' not found in this file and "
                f"could not be auto-appended from MN's asset blends. Check "
                f"the spelling (e.g. 'Style Cartoon', 'Style Ball and "
                f"Stick', 'Style Spheres', 'Style Surface'), or add it once "
                f"via Shift+A -> Molecular Nodes -> Style, then re-run.")

        int_ids = resolve_ids(chain_ids)

        # style node
        style_node = nodes.new("GeometryNodeGroup")
        style_node.node_tree = style_ng
        style_node.location = (500, y)
        links.new(source_socket, geo_in(style_node))

        # stock-node chain selection -> style node's Selection input
        sel_out = make_chain_selection(int_ids, y) if int_ids else None
        sel_in = next((s for s in style_node.inputs
                       if s.name.lower() == "selection"), None)
        if sel_in and sel_out:
            links.new(sel_out, sel_in)

        links.new(geo_out(style_node), join.inputs[0])
        y -= 400

    print(f"[style_by_chain] Applied {list(chain_styles)} to "
          f"'{mol_obj.name}' using chain_id map {chain_map or '(ordinal)'}. "
          f"If a chain looks wrong, check the Named Attribute 'chain_id' "
          f"values in the MN spreadsheet.")
    return tree

# ============================================================
# nanoparticle_from_pmhc  --  multivalent iron-oxide NP flyer
# ============================================================
# Builds the therapeutic as it is described in the platform spec: an
# iron-oxide core with several wheat pMHC-II copies conjugated to its
# surface (Navacim-style multivalent scaffold). Each NP is parented to a
# "driver" object (the existing *_wheat_pMHC_PLACEHOLDER cylinders), so it
# INHERITS the fly-in/dock keyframes already on that driver -- no need to
# re-animate. The driver is then hidden from render.
#
# Usage in the Console (after style_by_chain has styled wheat_pmhc):
#
#   nanoparticle_from_pmhc(bpy.data.objects["wheat_pmhc"])
#
# It auto-finds the three placeholder drivers. Tune look via kwargs:
#   n_studs, core_radius, stud_len, core_color.

import math
import mathutils

def _fib_sphere_dirs(n):
    """n roughly-even unit directions on a sphere (Fibonacci spiral)."""
    dirs = []
    for i in range(n):
        theta = math.acos(1 - 2 * (i + 0.5) / n)
        phi = math.pi * (1 + 5 ** 0.5) * i
        dirs.append(mathutils.Vector((
            math.sin(theta) * math.cos(phi),
            math.sin(theta) * math.sin(phi),
            math.cos(theta))))
    return dirs

def _iron_oxide_material(name="IronOxide_NP", color=(0.05, 0.03, 0.02, 1.0)):
    mat = bpy.data.materials.new(name=name)
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes.get("Principled BSDF")
    if bsdf:
        bsdf.inputs["Base Color"].default_value = color
        bsdf.inputs["Metallic"].default_value = 1.0
        bsdf.inputs["Roughness"].default_value = 0.45
    return mat

def nanoparticle_from_pmhc(pmhc_obj, drivers=None, n_studs=8,
                           core_radius=0.4, stud_len=0.6,
                           core_color=(0.05, 0.03, 0.02, 1.0),
                           hide_source=True):
    """Build a multivalent pMHC nanoparticle on each driver object.

    pmhc_obj   : the styled Molecular Nodes pMHC (source for linked copies).
    drivers    : objects to parent each NP to (inherit their animation).
                 Default: all objects named '*_wheat_pMHC_PLACEHOLDER'.
    Returns the list of created core objects."""
    if drivers is None:
        drivers = [o for o in bpy.data.objects
                   if o.name.endswith("_wheat_pMHC_PLACEHOLDER")]
    if not drivers:
        raise RuntimeError(
            "No driver objects found. Pass drivers=[...] explicitly, or "
            "ensure the '*_wheat_pMHC_PLACEHOLDER' cylinders still exist "
            "(they carry the fly-in keyframes the NP will inherit).")

    # scale factor so each pMHC copy's longest dimension ~= stud_len
    dims = pmhc_obj.dimensions
    max_dim = max(dims) if max(dims) > 0 else 1.0
    stud_scale = pmhc_obj.scale * (stud_len / max_dim)

    coll = pmhc_obj.users_collection[0] if pmhc_obj.users_collection \
        else bpy.context.scene.collection
    core_mat = _iron_oxide_material(color=core_color)
    dirs = _fib_sphere_dirs(n_studs)
    stud_dist = core_radius + stud_len * 0.35
    up = mathutils.Vector((0, 0, 1))

    cores = []
    for d_i, driver in enumerate(drivers):
        # --- iron-oxide core, coincident with the driver, parented to it ---
        bpy.ops.mesh.primitive_uv_sphere_add(
            radius=core_radius, segments=48, ring_count=24,
            location=driver.matrix_world.translation)
        core = bpy.context.active_object
        core.name = f"{driver.name.replace('_wheat_pMHC_PLACEHOLDER','')}_NP_Core"
        for p in core.data.polygons:
            p.use_smooth = True
        core.data.materials.append(core_mat)
        core.parent = driver
        core.matrix_parent_inverse = driver.matrix_world.inverted()
        bpy.context.view_layer.update()

        # --- pMHC studs (linked copies -> share styled node tree/mesh) ---
        for s_i, dir in enumerate(dirs):
            stud = pmhc_obj.copy()           # linked: shares data + node group
            coll.objects.link(stud)
            stud.name = f"{core.name}_pMHC_{s_i:02d}"
            # world transform first, then parent keep-transform
            stud.scale = stud_scale
            stud.rotation_mode = 'QUATERNION'
            stud.rotation_quaternion = up.rotation_difference(dir)
            stud.location = core.matrix_world.translation + dir * stud_dist
            bpy.context.view_layer.update()
            stud.parent = core
            stud.matrix_parent_inverse = core.matrix_world.inverted()

        # hide the driver cylinder (keeps driving the NP, just not drawn)
        driver.hide_render = True
        driver.hide_set(True)
        cores.append(core)

    if hide_source:
        pmhc_obj.hide_render = True
        pmhc_obj.hide_set(True)

    print(f"[nanoparticle_from_pmhc] Built {len(cores)} NP(s), {n_studs} "
          f"pMHC studs each, parented to {[d.name for d in drivers]}. "
          f"Source '{pmhc_obj.name}' hidden={hide_source}. Scrub the "
          f"timeline: each NP should fly in and dock like the old flyer.")
    return cores


# ============================================================
# pin_nanoparticles_to_cells  --  make docked NPs ride the cell
# ============================================================
# nanoparticle_from_pmhc parents each NP to a placeholder cylinder, whose
# keyframes fly it in and then HOLD it at a fixed world point (dock_loc)
# through the end. But after docking the cell keeps animating (anergy
# shrinks, deletion collapses to 0.25, treg pulses), so the docking
# receptor moves inward with the membrane while the NP stays pinned in
# space -> the NP visually detaches.
#
# This adds a Copy Location constraint to each NP core, targeting that
# cell's docking receptor (the camera-facing one, chosen the same way
# build_tcell picked dock_idx: min world-Y). Constraint influence is
# keyframed 0 during fly-in -> 1 at the dock frame (CONSTANT interp, no
# blend). Copy Location follows the receptor's POSITION only, so the NP
# rides the shrinking/pulsing cell but keeps its own size.
#
# Usage in the Console (after nanoparticle_from_pmhc):
#
#   pin_nanoparticles_to_cells(dock_frame=120)

def _iter_action_fcurves_safe(obj):
    """Yield the F-Curves of obj's action across Blender versions.
    <=4.3: action.fcurves. 5.x: slotted channelbags accessed via
    bpy_extras.anim_utils.action_get_channelbag_for_slot."""
    ad = obj.animation_data
    if not ad or not ad.action:
        return
    action = ad.action
    if hasattr(action, "fcurves"):        # legacy path (<=4.3)
        for fc in action.fcurves:
            yield fc
        return
    try:                                  # slotted path (5.x)
        from bpy_extras import anim_utils
        slot = ad.action_slot
        cbag = anim_utils.action_get_channelbag_for_slot(action, slot)
        if cbag:
            for fc in cbag.fcurves:
                yield fc
    except Exception:
        return


def pin_nanoparticles_to_cells(dock_frame=120, cores=None):
    """Pin each NP core to its cell's docking receptor from dock_frame on.

    dock_frame : frame at which the NP has arrived (matches the flyer's
                 kf_loc dock keyframe; default 120).
    cores      : NP core objects to pin. Default: all '*_NP_Core'.
    Returns the list of (core, receptor) pairs wired."""
    if cores is None:
        cores = [o for o in bpy.data.objects if o.name.endswith("_NP_Core")]
    if not cores:
        raise RuntimeError(
            "No '*_NP_Core' objects found. Run nanoparticle_from_pmhc first.")

    scene = bpy.context.scene
    wired = []
    for core in cores:
        prefix = core.name[:-len("_NP_Core")]        # 'Anergy_NP_Core'->'Anergy'
        receptors = [o for o in bpy.data.objects
                     if o.name.startswith(prefix + "_Receptor")]
        if not receptors:
            print(f"[pin_np] WARNING: no receptors found for '{prefix}'; "
                  f"skipping {core.name}.")
            continue
        # docking receptor = camera-facing one (min world-Y), same rule
        # build_tcell used to pick dock_idx.
        bpy.context.view_layer.update()
        dock_rec = min(receptors, key=lambda r: r.matrix_world.translation.y)

        # reuse an existing Copy Location constraint if present, else add
        con = next((c for c in core.constraints
                    if c.type == 'COPY_LOCATION'), None)
        if con is None:
            con = core.constraints.new('COPY_LOCATION')
        con.name = "Dock_Follow"
        con.target = dock_rec
        con.use_x = con.use_y = con.use_z = True
        con.use_offset = False          # snap TO receptor, don't add

        # keyframe influence: 0 up to the frame before dock, 1 from dock on
        con.influence = 0.0
        con.keyframe_insert("influence", frame=max(1, dock_frame - 1))
        con.influence = 1.0
        con.keyframe_insert("influence", frame=dock_frame)

        # make those keyframes CONSTANT so influence steps, never blends.
        # Use a version-safe fcurve accessor: Blender 5.x removed
        # Action.fcurves in favour of slotted channelbags.
        for fc in _iter_action_fcurves_safe(core):
            if fc.data_path.endswith("influence"):
                for kp in fc.keyframe_points:
                    kp.interpolation = 'CONSTANT'
        wired.append((core, dock_rec))

    scene.frame_set(scene.frame_start)
    print(f"[pin_np] Pinned {len(wired)} NP(s) to their docking receptor "
          f"from frame {dock_frame}: "
          f"{[(c.name, r.name) for c, r in wired]}. Scrub past frame "
          f"{dock_frame}: each NP should now ride its cell as it "
          f"shrinks/pulses.")
    return wired
