"""
geometry.py
-----------
Procedural torus-knot geometry engine.

Key design choice (P2 fix)
--------------------------
`_make_torus_knot` no longer reads from `bpy.context`.  The three
scene-global parameters that change between calls —
  resolution, bevel_resolution, knot_scale
— are passed in as explicit keyword arguments by the caller (either
`knot_frame_handler` or `KNOT_OT_BakePreview.execute`).  This makes
the function independently testable and keeps geometry logic separate
from scene-state queries.

Torus-knot parametric equations
--------------------------------
For each link l of gcd total links:

    t    ∈ [0, 2π)   (steps = resolution)
    rev  = (p/gcd)·t + rP + (l·2π/gcd)
    spin = (q/gcd)·t + sP
    rad  = R + r·cos(spin·v)
    x    = rad·cos(rev·u)
    y    = rad·sin(rev·u)
    z    = r·sin(spin·v)·h
"""
from __future__ import annotations

import math

import bpy

from .constants import KNOT_OBJ_NAME


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def _make_torus_knot(
    config: dict,
    *,
    resolution: int,
    bevel_resolution: int,
    knot_scale: float,
    scene=None,
    turbulence: float = 0.0,
    smooth_shading: bool = True,
) -> bpy.types.Object:
    """Procedurally build / update the AnimKnot NURBS curve in-place.

    This avoids ALL crashes associated with bpy.ops, edit-mode switching,
    and dependency-graph race conditions during playback or rendering.

    Parameters
    ----------
    config:
        KnotConfig dict produced by ``KnotItem.to_dict()`` (optionally
        augmented by the frame handler with ``_scale_override`` /
        ``_skip_material`` keys).
    resolution:
        Number of NURBS control points per spline.
    bevel_resolution:
        Curve bevel resolution (controls tube facets).
    knot_scale:
        Global scale factor from ``KnotGlobalSettings.knot_scale``.
    scene:
        The active Blender scene.  Used only when the AnimKnot object does
        not yet exist and needs to be linked to the scene collection.
        Falls back to ``bpy.context.scene`` if not provided.
    """
    if scene is None:
        scene = bpy.context.scene

    # 1. Find or create the reusable curve object
    obj = bpy.data.objects.get(KNOT_OBJ_NAME)
    if not obj:
        curve_data = bpy.data.curves.new(name=KNOT_OBJ_NAME, type='CURVE')
        obj = bpy.data.objects.new(KNOT_OBJ_NAME, curve_data)
        scene.collection.objects.link(obj)
    else:
        curve_data = obj.data
        # NOTE: Do NOT call curve_data.splines.clear() here.
        # The rendered viewport keeps an evaluated depsgraph copy alive during
        # frame_change_post.  Clearing splines frees the underlying C SplinePoint
        # arrays while the evaluator's GeomCache still holds a pointer to them.
        # TBB's allocator then detects the use-after-free on the next malloc and
        # raises EXCEPTION_ACCESS_VIOLATION inside tbbmalloc.dll.
        # Instead we reconcile the spline count in-place (add/remove from tail)
        # and only reallocate point arrays when the count actually changes.

    # 2. Curve geometry properties
    curve_data.dimensions      = '3D'
    curve_data.fill_mode       = 'FULL'
    curve_data.extrude         = config.get('geo_extrude', 0.0)
    curve_data.offset          = config.get('geo_offset', 0.0)
    curve_data.bevel_depth     = config.get('geo_bDepth', 0.04)
    curve_data.bevel_resolution = bevel_resolution  # ← no bpy.context read

    # 3. Resolve shape parameters
    shape_type = config.get('shape_type', 'TORUS_KNOT')
    
    rP = config.get('torus_rP', 0.0) * math.pi * 2.0
    sP = config.get('torus_sP', 0.0) * math.pi * 2.0
    
    if shape_type == 'TORUS_KNOT':
        p = config.get('torus_p', 2)
        q = config.get('torus_q', 3)
        if config.get('flip_p', False): p = -p
        if config.get('flip_q', False): q = -q

        mode = config.get('mode', 'MAJOR_MINOR')
        if mode == 'EXT_INT':
            eR = config.get('torus_eR', 3.0)
            iR = config.get('torus_iR', 1.0)
            R  = (eR + iR) / 2.0
            r  = (eR - iR) / 2.0
        else:
            R = config.get('torus_R', 2.0)
            r = config.get('torus_r', 1.0)

        u  = config.get('torus_u', 1)
        v  = config.get('torus_v', 1)
        h  = config.get('torus_h', 1.0)

        multiple_links = config.get('multiple_links', False)
        gcd = math.gcd(abs(p), abs(q)) if p and q else 1
        if not multiple_links:
            gcd = 1
        is_cyclic = True

    elif shape_type == 'MOBIUS':
        twists = config.get('mobius_twists', 1)
        w = config.get('mobius_width', 1.0)
        R = config.get('torus_R', 2.0)  # Use torus major radius for the ring size
        gcd = 2 if twists % 2 == 0 else 1
        is_cyclic = True

    elif shape_type == 'LISSAJOUS':
        kx = config.get('liss_kx', 3)
        ky = config.get('liss_ky', 2)
        kz = config.get('liss_kz', 4)
        amp = config.get('liss_amp', 2.0)
        gcd = 1
        is_cyclic = True

    elif shape_type == 'SPIRAL':
        turns = config.get('spiral_turns', 10)
        R = config.get('spiral_R', 2.0)
        gcd = 1
        is_cyclic = False

    else:
        gcd = 1
        is_cyclic = True


    # 4. Reconcile spline count without freeing existing splines.
    steps         = resolution  # ← no bpy.context read
    current_count = len(curve_data.splines)
    if current_count < gcd:
        for _ in range(gcd - current_count):
            sp = curve_data.splines.new('NURBS')
            sp.use_cyclic_u   = is_cyclic
            sp.use_endpoint_u = not is_cyclic
    elif current_count > gcd:
        for _ in range(current_count - gcd):
            curve_data.splines.remove(curve_data.splines[-1])

    # 5. Write point coordinates in-place for each spline.
    TAU = 2.0 * math.pi
    for link, spline in enumerate(curve_data.splines):
        spline.use_cyclic_u   = is_cyclic
        spline.use_endpoint_u = not is_cyclic

        # Grow or shrink the point array only when the size changes.
        current_pts = len(spline.points)
        if current_pts < steps:
            spline.points.add(steps - current_pts)
        elif current_pts > steps:
            curve_data.splines.remove(spline)
            spline = curve_data.splines.new('NURBS')
            spline.use_cyclic_u   = is_cyclic
            spline.use_endpoint_u = not is_cyclic
            spline.points.add(steps - 1)

        for i in range(steps):
            if shape_type == 'TORUS_KNOT':
                t_param = (i / steps) * TAU
                rev  = (p / gcd) * t_param + rP + (link * TAU / gcd)
                spin = (q / gcd) * t_param + sP
                rad = R + r * math.cos(spin * v)
                x   = rad * math.cos(rev * u)
                y   = rad * math.sin(rev * u)
                z   = r   * math.sin(spin * v) * h

            elif shape_type == 'MOBIUS':
                t_max = 2.0 * TAU if gcd == 1 else TAU
                t_param = (i / steps) * t_max
                w_eff = w if link == 0 else -w
                
                t_param_rot = t_param + rP
                twist_angle = twists * t_param_rot / 2.0 + sP
                
                x = (R + w_eff * math.cos(twist_angle)) * math.cos(t_param_rot)
                y = (R + w_eff * math.cos(twist_angle)) * math.sin(t_param_rot)
                z = w_eff * math.sin(twist_angle)

            elif shape_type == 'LISSAJOUS':
                t_param = (i / steps) * TAU
                x = amp * math.sin(kx * t_param + rP)
                y = amp * math.sin(ky * t_param + (TAU / 4.0) + sP)
                z = amp * math.sin(kz * t_param + rP + sP)

            elif shape_type == 'SPIRAL':
                t_param = -math.pi / 2.0 + (i / max(1, steps - 1)) * math.pi
                theta = t_param
                phi = turns * 2.0 * t_param + rP + sP
                x = R * math.cos(theta) * math.cos(phi)
                y = R * math.cos(theta) * math.sin(phi)
                z = R * math.sin(theta)
                
            else:
                x, y, z = 0.0, 0.0, 0.0

            if turbulence > 0.0:
                nx = math.sin(i * 1.345 + rP) * turbulence
                ny = math.cos(i * 0.932 - sP) * turbulence
                nz = math.sin(i * 1.777 + rP + sP) * turbulence
                x += nx
                y += ny
                z += nz

            spline.points[i].co = (x, y, z, 1.0)

    # 6. Transform and material
    obj.location = (0.0, 0.0, 0.0)

    # Apply per-knot scale oscillation override if set by the handler
    scale_override = config.get("_scale_override", None)
    effective_scale = knot_scale if scale_override is None else knot_scale * scale_override
    obj.scale = (effective_scale, effective_scale, effective_scale)

    # Assign material unless the handler has already set up a cross-shader blend
    if not config.get("_skip_material", False):
        if config.get("material_mode") == 'PROJECT':
            mat = config.get("project_material")
        else:
            uid = config.get("uid")
            mat = bpy.data.materials.get(f"KnotItem_Preset_{uid}")

        if mat:
            if len(obj.data.materials) == 0:
                obj.data.materials.append(mat)
            else:
                obj.data.materials[0] = mat

    return obj


def _remove_existing_knot() -> None:
    """Remove the AnimKnot object and its curve data block from the scene.

    Called by ``unregister()`` to clean up when the script is reloaded or
    disabled.  ``_make_torus_knot()`` reuses the object in-place during
    animation playback, so this function is intentionally NOT called there.
    """
    obj = bpy.data.objects.get(KNOT_OBJ_NAME)
    if obj is not None:
        curve_data = obj.data
        bpy.data.objects.remove(obj, do_unlink=True)
        if curve_data and curve_data.users == 0:
            bpy.data.curves.remove(curve_data)
